mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 22:13:01 +00:00
Correctness - /api/remote never recorded source.created, so URL, GitHub and connector sources had a source.deleted with no matching creation. All three creation paths now go through one _audit_source_created helper. - The prompt-cache rate divided cached tokens by a whole bucket's prompt tokens. A bucket is a day and mixes calls whose provider reports a cache breakdown with calls whose provider does not, so filtering buckets in the client could not separate them and the rate was understated by however much traffic ran on a non-reporting provider. The denominator is now computed in SQL over the reporting rows. - The outcome pill matched values nothing writes. Guardrails emit triggered / not_evaluated and the device feed emits dispatched; the map had blocked / denied / allowed, so a guardrail that fired rendered neutral grey -- the one signal the merged feed exists to surface. Fixtures were seeding the fictional values, so the tests passed on it too. - Stream duration_ms timed the consumer. stream_token_usage is a generator, so start-to-exhaustion includes the agent loop's tool handling and the SSE client's pace; a slow browser recorded ~30s for a sub-second call. It now accumulates only the time spent inside next(). Safety - Activity filters failed open: an unknown facet or unparseable timestamp was dropped, and no filter means every row, so a typo widened an audit view and on the export streamed the full history. Both are now a 400. - The search term was interpolated into an ILIKE pattern, so "100%" matched everything and "q1_report" matched more than it should. Escaped. - 0034 set actor_id NOT NULL with no default. A previous-release process inserting mid-rollout would raise, and in admin/routes.py that insert shares the request transaction, so a role grant beside it would roll back too. Noise and dead code - The per-user panel is a security panel: data-plane events file under the actor, so an active account's routine deletes pushed a denied login out of the 20-row window. It now excludes them; the Activity tab shows everything. - device_audit_log had no created_at-leading index, so the merged feed sequentially scanned that branch every page (migration 0036). - conversation.deleted_all no longer records when nothing was deleted, and agent.updated no longer records an empty field list. - Dropped by_model from /admin/usage (no consumer; an extra aggregate per page load), the duplicate filter surface on AuthEventsRepository that nothing called, and the unreachable FLOW_LABELS.schedule entry. - Type hints on record_event's conn and the remaining unannotated helpers.
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""Endpoint tests for the merged admin activity feed, its filters and export.
|
|
|
|
The repository's SQL is covered by
|
|
tests/storage/db/repositories/test_activity.py; these pin the route wiring,
|
|
the @admin_required boundary, query-argument parsing and the export framing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
from contextlib import ExitStack, contextmanager
|
|
from datetime import datetime, timezone
|
|
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(repo: Mock):
|
|
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.activity.db_readonly", _fake_conn))
|
|
stack.enter_context(
|
|
patch(
|
|
"docsgpt.api.admin.activity.ActivityRepository", return_value=repo
|
|
)
|
|
)
|
|
yield
|
|
|
|
|
|
def _row(**overrides):
|
|
base = {
|
|
"feed": "auth",
|
|
"id": "1",
|
|
"event": "oidc_login",
|
|
"category": "identity",
|
|
"actor_id": "u1",
|
|
"target_id": "u1",
|
|
"ip": "203.0.113.1",
|
|
"user_agent": "curl/8",
|
|
"outcome": None,
|
|
"detail": {"via": "oidc"},
|
|
"created_at": datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def _repo(rows=None, total=None):
|
|
repo = Mock()
|
|
rows = [_row()] if rows is None else rows
|
|
repo.list.return_value = rows
|
|
repo.count.return_value = len(rows) if total is None else total
|
|
repo.iter_all.return_value = iter(rows)
|
|
repo.event_names.return_value = [
|
|
{"event": "oidc_login", "category": "identity"},
|
|
]
|
|
return repo
|
|
|
|
|
|
@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/activity").status_code == 403
|
|
assert client.get("/api/admin/activity/export").status_code == 403
|
|
assert client.get("/api/admin/activity/events").status_code == 403
|
|
|
|
def test_unauthenticated(self, client):
|
|
with patch("docsgpt.app.handle_auth", return_value=None):
|
|
assert client.get("/api/admin/activity").status_code == 401
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFeed:
|
|
def test_returns_rows_with_iso_timestamps(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
body = json.loads(client.get("/api/admin/activity").data)
|
|
assert body["success"] is True
|
|
assert body["activity"][0]["created_at"] == "2026-01-02T03:04:05+00:00"
|
|
assert body["total"] == 1
|
|
assert body["has_more"] is False
|
|
|
|
def test_has_more_when_the_page_is_short_of_the_total(self, client):
|
|
repo = _repo(total=500)
|
|
with _admin(repo):
|
|
body = json.loads(client.get("/api/admin/activity").data)
|
|
assert body["has_more"] is True
|
|
|
|
def test_filters_reach_the_repository(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
client.get(
|
|
"/api/admin/activity?feed=auth,device&category=data"
|
|
"&event=source.deleted&actor_id=admin1&user_id=u2"
|
|
"&since=2026-01-01T00:00:00Z&until=2026-02-01&search=handbook"
|
|
)
|
|
kwargs = repo.list.call_args.kwargs
|
|
assert kwargs["feeds"] == ["auth", "device"]
|
|
assert kwargs["categories"] == ["data"]
|
|
assert kwargs["events"] == ["source.deleted"]
|
|
assert kwargs["actor_id"] == "admin1"
|
|
assert kwargs["user_id"] == "u2"
|
|
assert kwargs["since"] == datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
# A naive bound is read as UTC.
|
|
assert kwargs["until"] == datetime(2026, 2, 1, tzinfo=timezone.utc)
|
|
assert kwargs["search"] == "handbook"
|
|
|
|
def test_unknown_facet_is_refused_not_dropped(self, client):
|
|
"""Dropping it would mean no filter, and no filter means everything.
|
|
|
|
A typo or a stale chip must never widen an audit view.
|
|
"""
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
resp = client.get("/api/admin/activity?category=bogus")
|
|
assert resp.status_code == 400
|
|
assert "bogus" in json.loads(resp.data)["message"]
|
|
repo.list.assert_not_called()
|
|
|
|
def test_unknown_feed_is_refused(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
assert client.get("/api/admin/activity?feed=bogus").status_code == 400
|
|
repo.list.assert_not_called()
|
|
|
|
def test_unparseable_time_is_refused(self, client):
|
|
"""Ignoring it would drop the bound and return the full history."""
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
assert client.get("/api/admin/activity?since=nonsense").status_code == 400
|
|
repo.list.assert_not_called()
|
|
|
|
def test_a_valid_facet_still_passes_through(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
assert client.get("/api/admin/activity?category=data").status_code == 200
|
|
assert repo.list.call_args.kwargs["categories"] == ["data"]
|
|
|
|
def test_repeated_params_accumulate(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
client.get("/api/admin/activity?category=data&category=access")
|
|
assert repo.list.call_args.kwargs["categories"] == ["data", "access"]
|
|
|
|
def test_search_is_length_capped(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
client.get("/api/admin/activity?search=" + "x" * 5000)
|
|
assert len(repo.list.call_args.kwargs["search"]) == 200
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCatalogue:
|
|
def test_lists_events_categories_and_feeds(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
body = json.loads(client.get("/api/admin/activity/events").data)
|
|
assert body["events"] == [{"event": "oidc_login", "category": "identity"}]
|
|
assert "identity" in body["categories"]
|
|
assert body["feeds"] == ["auth", "device", "guardrail"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExport:
|
|
"""The export streams lazily, so every test consumes it inside the patch."""
|
|
|
|
def test_csv_has_a_header_and_one_row_per_event(self, client):
|
|
repo = _repo(rows=[_row(), _row(id="2", event="pat_created")])
|
|
with _admin(repo):
|
|
resp = client.get("/api/admin/activity/export?format=csv")
|
|
body = resp.get_data(as_text=True)
|
|
assert resp.status_code == 200
|
|
assert resp.mimetype == "text/csv"
|
|
assert "attachment; filename=" in resp.headers["Content-Disposition"]
|
|
rows = list(csv.DictReader(io.StringIO(body)))
|
|
assert [row["event"] for row in rows] == ["oidc_login", "pat_created"]
|
|
# ``detail`` is a JSON object, encoded into the cell.
|
|
assert json.loads(rows[0]["detail"]) == {"via": "oidc"}
|
|
|
|
def test_ndjson_emits_one_object_per_line(self, client):
|
|
repo = _repo(rows=[_row(), _row(id="2")])
|
|
with _admin(repo):
|
|
resp = client.get("/api/admin/activity/export?format=ndjson")
|
|
body = resp.get_data(as_text=True)
|
|
assert resp.mimetype == "application/x-ndjson"
|
|
lines = body.strip().split("\n")
|
|
assert [json.loads(line)["id"] for line in lines] == ["1", "2"]
|
|
|
|
def test_csv_cells_cannot_smuggle_a_spreadsheet_formula(self, client):
|
|
"""``user_agent`` is an unauthenticated attacker's raw header."""
|
|
repo = _repo(
|
|
rows=[_row(user_agent="=cmd|'/c calc'!A0", actor_id="+1", ip="-2")]
|
|
)
|
|
with _admin(repo):
|
|
body = client.get(
|
|
"/api/admin/activity/export?format=csv"
|
|
).get_data(as_text=True)
|
|
row = next(csv.DictReader(io.StringIO(body)))
|
|
assert row["user_agent"].startswith("'=")
|
|
assert row["actor_id"].startswith("'+")
|
|
assert row["ip"].startswith("'-")
|
|
|
|
def test_csv_leaves_ordinary_cells_alone(self, client):
|
|
with _admin(_repo()):
|
|
body = client.get(
|
|
"/api/admin/activity/export?format=csv"
|
|
).get_data(as_text=True)
|
|
row = next(csv.DictReader(io.StringIO(body)))
|
|
assert row["user_agent"] == "curl/8"
|
|
assert row["event"] == "oidc_login"
|
|
|
|
def test_empty_csv_export_still_has_its_header(self, client):
|
|
with _admin(_repo(rows=[])):
|
|
body = client.get("/api/admin/activity/export").get_data(as_text=True)
|
|
assert body.strip().startswith("feed,id,event,category")
|
|
|
|
def test_export_refuses_an_unknown_facet(self, client):
|
|
"""Failing open here would stream the whole history to the 100k cap."""
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
resp = client.get("/api/admin/activity/export?category=bogus")
|
|
resp.get_data()
|
|
assert resp.status_code == 400
|
|
repo.iter_all.assert_not_called()
|
|
|
|
def test_rejects_an_unknown_format(self, client):
|
|
with _admin(_repo()):
|
|
assert (
|
|
client.get("/api/admin/activity/export?format=xlsx").status_code == 400
|
|
)
|
|
|
|
def test_export_applies_the_same_filters(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
client.get(
|
|
"/api/admin/activity/export?category=data&search=q"
|
|
).get_data()
|
|
kwargs = repo.iter_all.call_args.kwargs
|
|
assert kwargs["categories"] == ["data"]
|
|
assert kwargs["search"] == "q"
|
|
|
|
def test_row_cap_is_bounded_and_reported(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
resp = client.get("/api/admin/activity/export?max_rows=10")
|
|
resp.get_data()
|
|
assert resp.headers["X-Export-Max-Rows"] == "10"
|
|
assert repo.iter_all.call_args.kwargs["max_rows"] == 10
|
|
|
|
def test_an_absurd_cap_clamps_to_the_ceiling(self, client):
|
|
repo = _repo()
|
|
with _admin(repo):
|
|
resp = client.get("/api/admin/activity/export?max_rows=999999999")
|
|
resp.get_data()
|
|
assert resp.headers["X-Export-Max-Rows"] == "100000"
|
|
|
|
def test_export_is_not_cached(self, client):
|
|
with _admin(_repo()):
|
|
resp = client.get("/api/admin/activity/export")
|
|
resp.get_data()
|
|
assert resp.headers["Cache-Control"] == "no-store"
|