Consolidates the scattered per-analyst pages into /me/activity (usage analytics) and /me/profile (account hub). /me/stats and /profile/sessions 301-redirect; /profile, /me/debug, /tokens are removed with every internal link repointed. Includes an XSS fix in the /me/activity page hero, the user_id-keyed session-lookup alignment, and the v0.54.15 release cut. Co-developed by @ZdenekSrotyr and @cvrysanek.
269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""/api/me/stats/* — per-user dashboard endpoints.
|
|
|
|
Coverage:
|
|
- All four endpoints scope rows to ``user["id"]`` / username so user A
|
|
cannot read user B's data (gates are server-side; the page renders a
|
|
shell with no caller-scope params).
|
|
- Empty user returns zero-counts / empty arrays, not 500.
|
|
- Tokens aggregates daily series, by-model, top-N, and lifetime totals
|
|
from a seeded sample.
|
|
- Sync activity surfaces both ``manifest.fetch`` and ``sync.trigger``,
|
|
filtered to caller.
|
|
- GET /api/sync/manifest writes the ``manifest.fetch`` audit row.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
|
|
import duckdb
|
|
import pytest
|
|
|
|
from src.db import _SYSTEM_SCHEMA
|
|
from src.repositories.audit import AuditRepository
|
|
|
|
|
|
@pytest.fixture
|
|
def stats_conn(tmp_path):
|
|
db = tmp_path / "system.duckdb"
|
|
conn = duckdb.connect(str(db))
|
|
conn.execute(_SYSTEM_SCHEMA)
|
|
return conn
|
|
|
|
|
|
def _seed_user(conn, *, uid, email):
|
|
conn.execute(
|
|
"INSERT INTO users (id, email, active, onboarded) VALUES (?, ?, TRUE, TRUE)",
|
|
[uid, email],
|
|
)
|
|
|
|
|
|
def _seed_session(conn, *, sf, username, started_sql, model="claude-opus-4-7",
|
|
user_messages=0, tool_calls=0,
|
|
input_tokens=0, output_tokens=0,
|
|
cache_read=0, cache_creation=0):
|
|
conn.execute(
|
|
f"""
|
|
INSERT INTO usage_session_summary
|
|
(session_file, session_id, username, started_at, ended_at,
|
|
active_seconds, wall_seconds, user_messages, assistant_messages,
|
|
tool_calls, tool_errors, skill_invocations, subagent_dispatches,
|
|
mcp_calls, slash_commands, distinct_tools, distinct_skills,
|
|
primary_model, input_tokens, output_tokens, cache_read_tokens,
|
|
cache_creation_tokens, processor_version)
|
|
VALUES (?, ?, ?, {started_sql}, current_timestamp,
|
|
10, 30, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, ?, ?, ?, ?, ?, 2)
|
|
""",
|
|
[sf, sf, username, user_messages, user_messages,
|
|
tool_calls, model, input_tokens, output_tokens,
|
|
cache_read, cache_creation],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sessions tab
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sessions_endpoint_scopes_to_caller(stats_conn, tmp_path, monkeypatch):
|
|
"""User A's sessions endpoint must not return user B's rows."""
|
|
# Point session-fs scan at an empty dir so unprocessed-jsonl path is no-op.
|
|
monkeypatch.setenv("AGNES_SESSION_DATA_DIR", str(tmp_path / "noop"))
|
|
|
|
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
|
_seed_user(stats_conn, uid="ub", email="bob@example.com")
|
|
_seed_session(stats_conn, sf="a1.jsonl", username="ua",
|
|
started_sql="current_timestamp - INTERVAL 1 HOUR",
|
|
user_messages=4, input_tokens=100, output_tokens=50)
|
|
_seed_session(stats_conn, sf="b1.jsonl", username="ub",
|
|
started_sql="current_timestamp - INTERVAL 1 HOUR",
|
|
user_messages=9, input_tokens=999, output_tokens=999)
|
|
|
|
from app.api.me_stats import list_self_sessions
|
|
res_a = list_self_sessions(
|
|
limit=50, offset=0,
|
|
user={"id": "ua", "email": "alice@example.com"},
|
|
conn=stats_conn,
|
|
)
|
|
assert res_a["total"] == 1
|
|
assert res_a["rows"][0]["session_file"] == "a1.jsonl"
|
|
assert res_a["rows"][0]["user_messages"] == 4
|
|
assert res_a["rows"][0]["tokens_total"] == 150
|
|
|
|
|
|
def test_sessions_endpoint_pagination(stats_conn, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("AGNES_SESSION_DATA_DIR", str(tmp_path / "noop"))
|
|
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
|
for i in range(5):
|
|
_seed_session(stats_conn, sf=f"s{i}.jsonl", username="ua",
|
|
started_sql=f"current_timestamp - INTERVAL {i} HOUR",
|
|
user_messages=i)
|
|
|
|
from app.api.me_stats import list_self_sessions
|
|
page1 = list_self_sessions(
|
|
limit=2, offset=0,
|
|
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
|
)
|
|
page2 = list_self_sessions(
|
|
limit=2, offset=2,
|
|
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
|
)
|
|
assert page1["total"] == 5
|
|
assert len(page1["rows"]) == 2
|
|
assert len(page2["rows"]) == 2
|
|
assert page1["rows"][0]["session_file"] != page2["rows"][0]["session_file"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tokens tab
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_tokens_endpoint_empty_user(stats_conn):
|
|
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
|
from app.api.me_stats import get_tokens
|
|
res = get_tokens(
|
|
days=30,
|
|
user={"id": "ua", "email": "alice@example.com"},
|
|
conn=stats_conn,
|
|
)
|
|
assert res["totals"]["total"] == 0
|
|
assert res["daily"] == []
|
|
assert res["by_model"] == []
|
|
assert res["top_sessions"] == []
|
|
|
|
|
|
def test_tokens_endpoint_aggregates(stats_conn):
|
|
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
|
_seed_session(stats_conn, sf="x.jsonl", username="ua",
|
|
started_sql="current_timestamp - INTERVAL 1 HOUR",
|
|
model="claude-opus-4-7",
|
|
input_tokens=100, output_tokens=50,
|
|
cache_read=800, cache_creation=25)
|
|
_seed_session(stats_conn, sf="y.jsonl", username="ua",
|
|
started_sql="current_timestamp - INTERVAL 2 DAY",
|
|
model="claude-sonnet-4-6",
|
|
input_tokens=200, output_tokens=100,
|
|
cache_read=400, cache_creation=10)
|
|
# Far-past row excluded by `days=7` window for the daily series, but
|
|
# still counted in lifetime totals + by_model + top.
|
|
_seed_session(stats_conn, sf="z.jsonl", username="ua",
|
|
started_sql="current_timestamp - INTERVAL 60 DAY",
|
|
model="claude-opus-4-7",
|
|
input_tokens=1, output_tokens=1)
|
|
|
|
from app.api.me_stats import get_tokens
|
|
res = get_tokens(
|
|
days=7,
|
|
user={"id": "ua", "email": "alice@example.com"},
|
|
conn=stats_conn,
|
|
)
|
|
# Lifetime totals include all three sessions
|
|
assert res["totals"]["sessions"] == 3
|
|
assert res["totals"]["input"] == 301
|
|
assert res["totals"]["output"] == 151
|
|
assert res["totals"]["cache_read"] == 1200
|
|
assert res["totals"]["cache_creation"] == 35
|
|
assert res["totals"]["total"] == 1687
|
|
|
|
# Daily series window excludes the 60-day-old row
|
|
daily_sessions = sum(d["sessions"] for d in res["daily"])
|
|
assert daily_sessions == 2
|
|
|
|
# By-model: opus has 2 sessions (x + z), sonnet has 1 (y)
|
|
models = {m["model"]: m for m in res["by_model"]}
|
|
assert models["claude-opus-4-7"]["sessions"] == 2
|
|
assert models["claude-sonnet-4-6"]["sessions"] == 1
|
|
|
|
# Top sessions: largest first
|
|
assert res["top_sessions"][0]["session_file"] in ("x.jsonl", "y.jsonl")
|
|
assert res["top_sessions"][0]["total"] >= res["top_sessions"][1]["total"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data access tab
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_queries_endpoint_filters_to_query_actions(stats_conn):
|
|
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
|
repo = AuditRepository(stats_conn)
|
|
repo.log(user_id="ua", action="query.local", resource="orders",
|
|
result="ok", duration_ms=42)
|
|
repo.log(user_id="ua", action="query.remote", resource="web_sessions",
|
|
result="ok", duration_ms=1500)
|
|
# Non-query action — must not appear
|
|
repo.log(user_id="ua", action="manifest.fetch", result="ok")
|
|
# Same query action but different user — must not appear
|
|
repo.log(user_id="ub", action="query.local", result="ok")
|
|
|
|
from app.api.me_stats import list_self_queries
|
|
res = list_self_queries(
|
|
limit=50, cursor_ts=None, cursor_id=None,
|
|
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
|
)
|
|
assert len(res["rows"]) == 2
|
|
actions = {r["action"] for r in res["rows"]}
|
|
assert actions == {"query.local", "query.remote"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync activity tab
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_endpoint_returns_manifest_fetch_rows(stats_conn):
|
|
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
|
repo = AuditRepository(stats_conn)
|
|
repo.log(user_id="ua", action="manifest.fetch", resource="manifest",
|
|
result="ok", client_kind="api")
|
|
repo.log(user_id="ua", action="sync.trigger", result="ok")
|
|
# Other user — must not leak
|
|
repo.log(user_id="ub", action="manifest.fetch", result="ok")
|
|
# Unrelated action — must not surface
|
|
repo.log(user_id="ua", action="query.local", result="ok")
|
|
# Stamp last_pull_at so the header card has a value
|
|
stats_conn.execute(
|
|
"UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
|
|
["ua"],
|
|
)
|
|
|
|
from app.api.me_stats import list_self_sync_activity
|
|
res = list_self_sync_activity(
|
|
limit=50, cursor_ts=None, cursor_id=None,
|
|
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
|
|
)
|
|
actions = {r["action"] for r in res["rows"]}
|
|
assert actions == {"manifest.fetch", "sync.trigger"}
|
|
assert res["last_pull_at"] is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manifest endpoint writes the audit_log row
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_manifest_writes_audit_row(stats_conn, monkeypatch, tmp_path):
|
|
"""GET /api/sync/manifest must emit a manifest.fetch audit_log row
|
|
so the Sync activity tab can list per-pull history."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
_seed_user(stats_conn, uid="ua", email="alice@example.com")
|
|
|
|
from app.api.sync import sync_manifest
|
|
asyncio.run(
|
|
sync_manifest(
|
|
user={"id": "ua", "email": "alice@example.com"},
|
|
conn=stats_conn,
|
|
)
|
|
)
|
|
rows = stats_conn.execute(
|
|
"SELECT action, resource, result, client_kind FROM audit_log "
|
|
"WHERE user_id = ? ORDER BY timestamp DESC",
|
|
["ua"],
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
action, resource, result, client_kind = rows[0]
|
|
assert action == "manifest.fetch"
|
|
assert resource == "manifest"
|
|
assert result == "ok"
|
|
assert client_kind == "api"
|