Files
Juan DenisandClaude Fable 5 69136ef48f fix(servers): restore the Observed-mode surface lost in the recovery rebuild
Migrations 065/068 have always created servers.management_mode and
servers.allow_agent_update_observed, but the model fields,
MANAGEMENT_MODES, is_managed, and agent_registry.observed_blocked_count
were lost in the 2026-07 data loss — POST /management-mode and
GET /observed-status raised AttributeError, and a mode flip that got
past validation would land on a transient Python attribute and
evaporate. test_survey_api carried skip-stubs documenting the hole.

Restored: the two columns (specs mirror the migrations), the mode
constant and is_managed property, to_dict emission, and a minimal
observed-block counter on the registry. The four skip-stubs are now
real tests, including one proving the flip persists across sessions.
The dispatch-level Observe guard remains hollow and is tracked in
plan 82 §H. events_json on notification_preferences is likewise
re-mapped (column carried by migration 059; its resolution surface is
still lost) so pre-loss override data survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:45:37 -04:00

184 lines
7.3 KiB
Python

"""Survey API + permission/capability gating (plan 27 Phase 2, #5/#6).
Covers the honest-degrade path (offline / old agent), the happy path (snapshot
stored + returned), the catalog index, and the model-level permission scope that
gates dispatch.
Reconstructed for plan 42 Phase 1 from the fragmented ``test_survey_api`` pyc +
the surviving ``survey_service`` / ``app/api/survey.py``.
Post-loss recovery status: the survey blueprint registration came back via
core_blueprints, and the Observed-mode surface (``Server.management_mode`` /
``allow_agent_update_observed`` / ``MANAGEMENT_MODES`` / ``is_managed`` /
``agent_registry.observed_blocked_count``) was restored by plan 82 §D after
the migration↔model drift gate exposed it — see the section at the bottom.
The dispatch-level Observe guard remains hollow (plan 82 §H).
"""
import pytest
from app.services import survey_service
def _mk_server(db, name='observed-box'):
from app.models.server import Server
s = Server(name=name)
db.session.add(s)
db.session.commit()
return s
def _patch_agent(monkeypatch, *, connected=True, capable=True, result=None):
"""Point survey_service's agent_registry at a fake connected/capable agent."""
from app.services import agent_registry as reg_mod
reg = reg_mod.agent_registry
monkeypatch.setattr(reg, 'is_agent_connected', lambda sid: connected)
monkeypatch.setattr(reg, 'get_capabilities', lambda sid: {'survey': capable})
monkeypatch.setattr(
reg, 'send_command',
lambda server_id, action, params=None, user_id=None, timeout=None: (
result if result is not None else {'success': True, 'data': {}}))
# --- model-level permission scope (survives) -------------------------------- #
def test_survey_read_scope_gates_dispatch(app):
"""A server granted the ``survey:read`` scope passes has_permission; one
without it does not — the scope that gates survey dispatch."""
from app.models.server import Server
granted = Server(name='granted', permissions=['survey:read'])
denied = Server(name='denied', permissions=['docker:container:read'])
assert granted.has_permission('survey:read') is True
assert denied.has_permission('survey:read') is False
# --- catalog index (service surface behind the endpoint) -------------------- #
def test_probe_index_catalog(app):
body = survey_service.probe_index()
assert body['version'] == survey_service.catalog_version()
ids = {p['id'] for p in body['probes']}
assert 'nginx' in ids and 'foreign-panel' in ids
# --- honest-degrade path ---------------------------------------------------- #
def test_run_survey_degrades_when_agent_offline(app, db_session):
server = _mk_server(db_session)
result, error = survey_service.run_survey(server.id)
assert result is None
assert error['code'] == 'AGENT_OFFLINE' and error['status'] == 503
def test_run_survey_degrades_when_agent_uncapable(app, db_session, monkeypatch):
server = _mk_server(db_session)
_patch_agent(monkeypatch, connected=True, capable=False)
result, error = survey_service.run_survey(server.id)
assert result is None
assert error['code'] == 'SURVEY_UNSUPPORTED' and error['status'] == 409
# --- happy path (snapshot stored + returned) -------------------------------- #
def test_run_survey_stores_and_returns_snapshot(app, db_session, monkeypatch):
server = _mk_server(db_session)
payload = {
'catalog_version': 1,
'probes': {
'nginx': {'detected': True, 'service': {'active': True, 'ports': [80]},
'vhosts': [{'server_name': 'example.com', 'root': '/var/www/example'}]},
'foreign-panel': {'detected': True, 'markers': ['/usr/local/cpanel']},
},
}
_patch_agent(monkeypatch, result={'success': True, 'data': payload})
result, error = survey_service.run_survey(server.id)
assert error is None and result is not None
stored = survey_service.list_surveys(server.id)
assert len(stored) == 1
latest = survey_service.latest_survey(server.id).get_map()
assert latest['catalog_version'] == 1
assert any(s['id'] == 'nginx' for s in latest['services'])
assert latest['foreign_panel_detected'] is True
# --- diff (service surface behind /surveys/diff) ---------------------------- #
def test_diff_maps_reports_removed_service(app):
old = {'catalog_version': 1, 'services': [{'id': 'nginx', 'active': True, 'ports': [80]}]}
new = {'catalog_version': 1, 'services': []}
diff = survey_service.diff_maps(old, new)
removed = {row['id'] for row in diff['services']['removed']}
assert 'nginx' in removed
# --- Observed-mode surface (restored by plan 82 §D) ------------------------- #
# The migration↔model drift gate (test_migration_schema_drift) surfaced that
# migrations 065/068 carried servers.management_mode /
# allow_agent_update_observed while the model fields, MANAGEMENT_MODES,
# is_managed and the registry counter were lost in the recovery rebuild —
# leaving these routes raising AttributeError. The model surface is back;
# NOTE the dispatch-level Observe guard is still hollow (plan 82 §H), so
# observed_blocked_count only counts explicitly recorded blocks.
def test_is_managed_property(app):
from app import db
s = _mk_server(db)
assert s.management_mode == 'managed'
assert s.is_managed is True
s.management_mode = 'observed'
assert s.is_managed is False
def test_switch_management_mode(app, client):
from app import db
from app.models.server import Server
from factories import make_user, headers_for
with app.app_context():
server_id = _mk_server(db).id
admin_headers = headers_for(make_user(db, role='admin'))
res = client.post(f'/api/v1/servers/{server_id}/management-mode',
json={'mode': 'observed',
'allow_agent_update_observed': True},
headers=admin_headers)
assert res.status_code == 200, res.get_json()
body = res.get_json()
assert body['management_mode'] == 'observed'
assert body['allow_agent_update_observed'] is True
assert body['observed_blocked_count'] == 0
# The flip must PERSIST — before the columns were restored, the
# assignment landed on a transient Python attribute and evaporated.
with app.app_context():
db.session.expire_all()
stored = Server.query.get(server_id)
assert stored.management_mode == 'observed'
assert stored.allow_agent_update_observed is True
res = client.get(f'/api/v1/servers/{server_id}/observed-status',
headers=admin_headers)
assert res.status_code == 200
assert res.get_json()['management_mode'] == 'observed'
def test_switch_management_mode_rejects_bad_value(app, client):
from app import db
from factories import make_user, headers_for
with app.app_context():
server_id = _mk_server(db).id
admin_headers = headers_for(make_user(db, role='admin'))
res = client.post(f'/api/v1/servers/{server_id}/management-mode',
json={'mode': 'yolo'}, headers=admin_headers)
assert res.status_code == 400
assert 'mode must be one of' in res.get_json()['error']
def test_server_to_dict_includes_mode(app):
from app import db
s = _mk_server(db)
d = s.to_dict()
assert d['management_mode'] == 'managed'
assert d['allow_agent_update_observed'] is False