"""Endpoint tests for the admin dashboard (Phase 0 + Phase 1). Repos are mocked at the route layer (the real SQL is covered by tests/storage/db/repositories/test_admin_stats.py); these pin the route wiring, the @admin_required boundary, the audited mutations, and the safety guards. """ from __future__ import annotations import json from contextlib import ExitStack, contextmanager from unittest.mock import Mock, patch import pytest @pytest.fixture def client(): from docsgpt.app import app as flask_app flask_app.config["TESTING"] = True return flask_app.test_client() @contextmanager def _fake_conn(): yield Mock() @contextmanager def _admin(**route_patches): """Authenticate as admin and patch docsgpt.api.admin.routes.* members.""" with ExitStack() as stack: stack.enter_context( patch("docsgpt.app.handle_auth", return_value={"sub": "admin1"}) ) stack.enter_context( patch("docsgpt.app.resolve_roles", return_value=["admin", "user"]) ) stack.enter_context(patch("docsgpt.api.admin.routes.db_readonly", _fake_conn)) stack.enter_context(patch("docsgpt.api.admin.routes.db_session", _fake_conn)) for name, value in route_patches.items(): stack.enter_context(patch(f"docsgpt.api.admin.routes.{name}", value)) yield def _body(resp): return json.loads(resp.data) @pytest.mark.unit class TestGuard: def test_non_admin_forbidden(self, client): with patch("docsgpt.app.handle_auth", return_value={"sub": "u"}), patch( "docsgpt.app.resolve_roles", return_value=["user"] ): assert client.get("/api/admin/overview").status_code == 403 def test_unauthenticated(self, client): with patch("docsgpt.app.handle_auth", return_value=None): assert client.get("/api/admin/overview").status_code == 401 @pytest.mark.unit class TestOverview: def test_ok(self, client): stats = Mock() stats.overview.return_value = { "users": {"total": 3, "active": 3, "inactive": 0}, "admins": 1, "agents": 0, "sources": 0, "conversations": 0, "new_users_7d": 1, "active_users_30d": 2, "failed_logins_7d": 0, "tokens_30d": 42, } with _admin(AdminStatsRepository=Mock(return_value=stats)): resp = client.get("/api/admin/overview") assert resp.status_code == 200 data = _body(resp) assert data["users"]["total"] == 3 assert data["tokens_30d"] == 42 @pytest.mark.unit class TestAdminsAndRoles: def test_list_admins(self, client): roles = Mock() roles.list_admins.return_value = [ {"user_id": "a", "granted_at": None, "sources": ["manual"]} ] with _admin(UserRolesRepository=Mock(return_value=roles)): resp = client.get("/api/admin/admins") assert resp.status_code == 200 assert _body(resp)["admins"][0]["user_id"] == "a" def test_grant_admin_audited(self, client): roles = Mock() roles.grant.return_value = True events = Mock() with _admin( UserRolesRepository=Mock(return_value=roles), AuthEventsRepository=Mock(return_value=events), ): resp = client.post("/api/admin/users/alice/role") assert resp.status_code == 200 assert _body(resp)["granted"] is True roles.grant.assert_called_once() # audited as role_granted assert events.insert.call_args.args[1] == "role_granted" def test_revoke_blocks_last_admin(self, client): roles = Mock() roles.list_admins.return_value = [{"user_id": "alice"}] with _admin( UserRolesRepository=Mock(return_value=roles), AuthEventsRepository=Mock(return_value=Mock()), ): resp = client.delete("/api/admin/users/alice/role") assert resp.status_code == 409 roles.revoke.assert_not_called() def test_revoke_ok_when_other_admins_exist(self, client): roles = Mock() roles.list_admins.return_value = [{"user_id": "alice"}, {"user_id": "bob"}] roles.revoke.return_value = True events = Mock() with _admin( UserRolesRepository=Mock(return_value=roles), AuthEventsRepository=Mock(return_value=events), ): resp = client.delete("/api/admin/users/alice/role") assert resp.status_code == 200 roles.revoke.assert_called_once() assert events.insert.call_args.args[1] == "role_revoked" @pytest.mark.unit class TestUserLifecycle: def test_self_deactivation_blocked(self, client): with _admin(UsersRepository=Mock()): resp = client.patch("/api/admin/users/admin1", json={"active": False}) assert resp.status_code == 409 def test_bad_body(self, client): with _admin(UsersRepository=Mock()): resp = client.patch("/api/admin/users/bob", json={}) assert resp.status_code == 400 def test_user_not_found(self, client): users = Mock() users.get.return_value = None with _admin( UsersRepository=Mock(return_value=users), AuthEventsRepository=Mock(return_value=Mock()), ): resp = client.patch("/api/admin/users/ghost", json={"active": True}) assert resp.status_code == 404 def test_deactivate_revokes_sessions_and_audits(self, client): users = Mock() users.get.return_value = { "id": "00000000-0000-0000-0000-000000000001", "user_id": "bob", } users.set_active.return_value = {"active": False} events = Mock() with _admin( UsersRepository=Mock(return_value=users), AuthEventsRepository=Mock(return_value=events), ), patch("docsgpt.api.admin.routes.denylist") as dl: resp = client.patch("/api/admin/users/bob", json={"active": False}) assert resp.status_code == 200 dl.deny_user.assert_called_once_with("bob") assert events.insert.call_args.args[1] == "admin_user_deactivated" def test_force_logout(self, client): events = Mock() tokens = Mock() tokens.revoke_all_for_user.return_value = ["t1", "t2"] with _admin( AuthEventsRepository=Mock(return_value=events), PersonalAccessTokensRepository=Mock(return_value=tokens), ), patch("docsgpt.api.admin.routes.denylist") as dl: dl.deny_user.return_value = True resp = client.post("/api/admin/users/bob/revoke-sessions") assert resp.status_code == 200 dl.deny_user.assert_called_once_with("bob") # A forced logout also revokes the user's API tokens, one audit event each. tokens.revoke_all_for_user.assert_called_once_with("bob", reason="admin_sessions_revoked") recorded = [call.args[1] for call in events.insert.call_args_list] assert recorded == ["pat_revoked", "pat_revoked", "admin_sessions_revoked"] def test_user_detail(self, client): users = Mock() users.get.return_value = { "user_id": "bob", "active": True, "created_at": None, "updated_at": None, } roles = Mock() roles.role_names_for.return_value = ["admin"] roles.list_for.return_value = [] events = Mock() events.list_recent.return_value = [] stats = Mock() stats.user_counts.return_value = { "agents": 0, "sources": 0, "conversations": 0, "tokens_30d": 0, } with _admin( UsersRepository=Mock(return_value=users), UserRolesRepository=Mock(return_value=roles), AuthEventsRepository=Mock(return_value=events), AdminStatsRepository=Mock(return_value=stats), ): resp = client.get("/api/admin/users/bob") assert resp.status_code == 200 data = _body(resp) assert data["user"]["user_id"] == "bob" assert "admin" in data["roles"] and "user" in data["roles"] @pytest.mark.unit class TestUsageAndAudit: @staticmethod def _usage_repos(): usage = Mock() usage.bucketed_totals.return_value = [ { "bucket": "2026-06-14", "prompt_tokens": 5, "generated_tokens": 3, "cost": 0.25, "cached_tokens": None, "cache_eligible_prompt_tokens": 0, } ] usage.sum_tokens_in_range.return_value = 8 stats = Mock() stats.top_token_users.return_value = [ {"user_id": "a", "tokens": 8, "cost": 0.25} ] stats.latency_summary.return_value = { "samples": 2, "p50_ms": 120, "p95_ms": 400, "ttft_samples": 2, "ttft_p50_ms": 30, } return usage, stats def test_usage(self, client): usage, stats = self._usage_repos() with _admin( TokenUsageRepository=Mock(return_value=usage), AdminStatsRepository=Mock(return_value=stats), ): resp = client.get("/api/admin/usage?days=7&bucket=day") assert resp.status_code == 200 data = _body(resp) assert data["total_tokens"] == 8 assert len(data["series"]) == 1 assert data["top_users"][0]["user_id"] == "a" def test_usage_reports_spend_and_latency(self, client): """Cost quotas were settable long before spend was visible.""" usage, stats = self._usage_repos() with _admin( TokenUsageRepository=Mock(return_value=usage), AdminStatsRepository=Mock(return_value=stats), ): data = _body(client.get("/api/admin/usage")) assert data["total_cost"] == 0.25 assert data["series"][0]["cost"] == 0.25 assert data["latency"]["p95_ms"] == 400 assert data["top_users"][0]["cost"] == 0.25 def test_usage_does_not_run_an_aggregate_nothing_reads(self, client): """Per-model spend is served by the per-user endpoint and by group_by.""" usage, stats = self._usage_repos() with _admin( TokenUsageRepository=Mock(return_value=usage), AdminStatsRepository=Mock(return_value=stats), ): data = _body(client.get("/api/admin/usage")) assert "by_model" not in data usage.tokens_by_model.assert_not_called() def test_usage_group_by_reaches_the_repository(self, client): usage, stats = self._usage_repos() with _admin( TokenUsageRepository=Mock(return_value=usage), AdminStatsRepository=Mock(return_value=stats), ): assert client.get("/api/admin/usage?group_by=model").status_code == 200 assert usage.bucketed_totals.call_args.kwargs["group_by"] == "model" def test_usage_rejects_an_unknown_grouping(self, client): with _admin(): assert client.get("/api/admin/usage?group_by=nonsense").status_code == 400 def test_per_user_usage_drilldown(self, client): usage, stats = self._usage_repos() stats.user_usage_breakdown.return_value = { "totals": {"tokens": 8, "cost": 0.25, "calls": 1}, "by_model": [{"key": "gpt-x", "tokens": 8, "cost": 0.25}], "by_source": [{"key": "agent_stream", "tokens": 8, "cost": 0.25}], } with _admin( TokenUsageRepository=Mock(return_value=usage), AdminStatsRepository=Mock(return_value=stats), ): data = _body(client.get("/api/admin/users/u1/usage?days=7")) assert data["totals"]["cost"] == 0.25 assert data["by_source"][0]["key"] == "agent_stream" assert len(data["series"]) == 1 assert usage.bucketed_totals.call_args.kwargs["user_id"] == "u1" def test_per_user_usage_requires_admin(self, client): with patch("docsgpt.app.handle_auth", return_value={"sub": "u1"}), patch( "docsgpt.app.resolve_roles", return_value=["user"] ): assert client.get("/api/admin/users/u1/usage").status_code == 403 def test_usage_invalid_bucket(self, client): with _admin(): resp = client.get("/api/admin/usage?bucket=year") assert resp.status_code == 400 def test_audit_feed(self, client): repo = Mock() repo.count_all.return_value = 1 repo.list_all.return_value = [{"user_id": "a", "event": "oidc_login"}] with _admin(AuthEventsRepository=Mock(return_value=repo)): resp = client.get("/api/admin/audit?event=oidc_login&page=1&page_size=10") assert resp.status_code == 200 data = _body(resp) assert data["total"] == 1 assert data["events"][0]["event"] == "oidc_login" repo.list_all.assert_called_once() def test_device_audit_feed(self, client): repo = Mock() repo.count_global.return_value = 0 repo.list_global.return_value = [] with _admin(DeviceAuditLogRepository=Mock(return_value=repo)): resp = client.get("/api/admin/devices/audit?decision=denied") assert resp.status_code == 200 assert _body(resp)["invocations"] == []