mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 10:07:29 +09:00
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>
184 lines
7.3 KiB
Python
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
|