mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 20:12:55 +00:00
Three gaps in one view. Cost: token_usage.cost has been written on every call since quotas landed, and tokens_by_model already returned it, but bucketed_totals and top_token_users selected tokens only. An admin could set a USD quota in /admin/quotas and had no way to see the spend it was capping. Buckets and top users now carry cost, the endpoint reports a window total and a per-model split, and the chart takes a Tokens/Cost toggle with a currency axis. Group-by: the endpoint has supported group_by=model|agent|source from the start and the UI only ever sent bucket=day. The selector is now wired, and a grouped series pads missing buckets so a model that was idle on Tuesday plots a zero instead of shifting its whole row one bar left. Latency: surfaced from the columns the previous commit added, as p50/p95 with a median time-to-first-token underneath. Unmeasured rows are excluded rather than counted as zero, and the card says so when nothing was measured. Also surfaces the prompt-cache hit rate, computed only over rows whose provider reported a cache breakdown -- NULL means "not reported", and folding those in as 0% would understate it. Per-user drill-down: GET /api/admin/users/<id>/usage returns a daily series plus splits by model and by flow, reachable from the top-users table and from the user detail dialog. The detail dialog previously showed one tokens_30d number, which answers neither "what is this person costing" nor "what is driving it".
348 lines
13 KiB
Python
348 lines
13 KiB
Python
"""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,
|
|
}
|
|
]
|
|
usage.sum_tokens_in_range.return_value = 8
|
|
usage.tokens_by_model.return_value = [
|
|
{"model_id": "gpt-x", "tokens": 8, "cost": 0.25}
|
|
]
|
|
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["by_model"][0]["model_id"] == "gpt-x"
|
|
assert data["latency"]["p95_ms"] == 400
|
|
assert data["top_users"][0]["cost"] == 0.25
|
|
|
|
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"] == []
|