Files
DocsGPT/tests/api/test_audit_helper.py
T
arc53-machine c2d1893992 fix(admin): keyset export paging, rollup-free spend, consistent totals
Review pass over the preceding commits.

- The activity export paged by OFFSET. The journals are append-only and the
  feed is newest-first, so rows written mid-export shift the window down and
  repeat rows already emitted. Pages by keyset on (created_at, feed, id) now.
- top_token_users and the per-user breakdown counted run-level rollup rows.
  A scheduled run already has a row per LLM call, so its spend was billed
  twice -- invisible while the column was tokens, obvious once it was dollars.
  Both now exclude them, matching every other spend query.
- total_cost was summed from the per-model split, which drops rows with no
  model_id and so undercounted the figure printed above the series. Summed
  from the series instead.
- The CSV export encoded its detail cell without the fallback the NDJSON
  branch had, so a non-JSON-native value would have failed the stream.
- The activity view reset pagination in an effect, which fetched the stale
  page against the new filters before fetching again. Reset in the setters.
- The audit taxonomy is no longer re-exported from the helper module; the one
  caller that wanted it imports from where it lives.
2026-09-22 10:41:46 +01:00

125 lines
4.5 KiB
Python

"""Unit tests for the shared request-scoped audit helper."""
from __future__ import annotations
from unittest.mock import MagicMock, Mock, patch
import pytest
from docsgpt.api.audit import record_event
from docsgpt.audit_events import ACTIVITY_CATEGORIES, category_for
@pytest.fixture
def app():
from docsgpt.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app
@pytest.mark.unit
class TestRecordEvent:
def test_writes_actor_target_and_request_context(self, app):
repo = Mock()
conn = MagicMock()
with app.test_request_context(
"/api/anything",
environ_base={"REMOTE_ADDR": "203.0.113.7"},
headers={"User-Agent": "curl/8"},
):
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
record_event(
conn, "source.deleted", actor="u1", target=None, source_id="s1"
)
kwargs = repo.insert.call_args.kwargs
assert kwargs["event"] == "source.deleted"
assert kwargs["actor_id"] == "u1"
assert kwargs["target_id"] is None
assert kwargs["ip"] == "203.0.113.7"
assert kwargs["user_agent"] == "curl/8"
assert kwargs["metadata"] == {"source_id": "s1"}
def test_drops_none_metadata_values(self, app):
repo = Mock()
with app.test_request_context("/api/anything"):
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
record_event(MagicMock(), "agent.created", actor="u1", name=None, id="a1")
assert repo.insert.call_args.kwargs["metadata"] == {"id": "a1"}
def test_insert_failure_never_raises(self, app):
"""An audit write must not be able to fail the action it records."""
conn = Mock()
conn.begin_nested.side_effect = RuntimeError("boom")
with app.test_request_context("/api/anything"):
record_event(conn, "source.deleted", actor="u1")
def test_runs_in_a_savepoint(self, app):
"""A poisoned audit insert must roll back alone, not the action."""
repo = Mock()
conn = MagicMock()
with app.test_request_context("/api/anything"):
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
record_event(conn, "agent.deleted", actor="u1")
conn.begin_nested.assert_called_once()
def test_works_without_a_request_context(self, app):
"""Celery tasks have no request; the row still records."""
repo = Mock()
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
record_event(MagicMock(), "source.created", actor="u1")
kwargs = repo.insert.call_args.kwargs
assert kwargs["ip"] is None and kwargs["user_agent"] is None
def test_anonymous_actor_falls_back(self, app):
repo = Mock()
with app.test_request_context("/api/anything"):
with patch("docsgpt.api.audit.AuthEventsRepository", return_value=repo):
record_event(MagicMock(), "agent.created", actor=None)
assert repo.insert.call_args.kwargs["actor_id"] == "unknown"
@pytest.mark.unit
class TestCategories:
@pytest.mark.parametrize(
"event,expected",
[
("oidc_login", "identity"),
("oidc_login_denied", "identity"),
("pat_created", "identity"),
("scim_created", "identity"),
("role_granted", "access"),
("admin_user_deactivated", "access"),
("team.member_add", "access"),
("quota_policy_set", "config"),
("source.deleted", "data"),
("agent.created", "data"),
("conversation.deleted", "data"),
],
)
def test_known_events(self, event, expected):
assert category_for(event) == expected
def test_unknown_event_is_other(self):
assert category_for("something_new") == "other"
@pytest.mark.parametrize(
"event,expected",
[("device.run_command", "device"), ("guardrail.input", "safety")],
)
def test_merged_feed_namespaces(self, event, expected):
assert category_for(event) == expected
def test_every_produced_category_is_declared(self):
assert "other" in ACTIVITY_CATEGORIES
for event in (
"oidc_login",
"role_granted",
"quota_policy_set",
"source.deleted",
"device.run_command",
"guardrail.input",
"mystery",
):
assert category_for(event) in ACTIVITY_CATEGORIES