deer-flow/backend/tests/test_thread_token_usage.py
xiaodu55 16f154f32b
fix(gateway): preserve owner isolation when thread metadata is missing (#5484)
* fix(gateway): preserve owner isolation when thread metadata is missing

Follow-up to the #5448 review P1 (post-merge finding): owner_check=True
also authorizes threads whose meta row is missing (legacy compatibility)
or NULL-owner (shared/pre-auth data). _run_scope_user_id returned None
for every trusted internal caller, which dropped the only remaining
per-user filter on those threads and let an internal caller acting for
owner A read owner B's persisted runs.

_run_scope_user_id now takes the thread_id and consults the thread meta
store: when an existing meta row establishes ownership, the authorized
thread's runs are still read unfiltered (merged #5448 semantics,
including owner-header-less internal callers); when the meta row is
missing or NULL-owner, the filter falls back to the acting owner's raw
stamp (the exact value start_run writes) — or the synthetic "default"
identity without an owner header — so cross-user runs stay hidden.

Isolation coverage uses the real MemoryThreadMetaStore with no metadata
row (and a NULL-owner row) plus another user's persisted run: /runs and
/runs/page must be empty and /runs/{run_id} must 404 for internal
callers, while an established-ownership thread keeps the unfiltered
read.

* fix(gateway): gate run-scoped sub-resource reads for internal callers

Review follow-up on #5484: the P1 owner-isolation class remained
reachable through run-scoped sibling reads that apply no per-user filter
at all — /runs/{run_id}/messages, /events, /join, /stream and
/workspace-changes query by (thread_id, run_id) directly, so on
missing/NULL-owner threads an internal caller acting for owner A could
still read owner B's run content by id (verified 200 at the previous
head).

- Extract _thread_ownership_established (shared meta-row check) and add
  _require_run_visible_to_scope: for internal callers on threads without
  established ownership, the run's own user_id stamp must match the
  acting owner's raw value (or the legacy "default" stamp) or the read
  404s. Established-ownership threads and every non-internal caller keep
  their existing thread-scoped semantics.
- Wire the gate into join, stream, messages, events and
  workspace-changes; reword the now-stale messages comment to track the
  new scoping semantics.

Regression tests: sub-resource reads 404 for a mismatched internal
owner while the matching owner reads them normally, and the owner-less
fallback branch (synthetic "default" filter on missing-meta threads) is
pinned. Red confirmed against the pre-gate head.

* fix(gateway): gate cancel and artifact archive for internal callers

Review follow-up on #5484 round 2: POST /cancel resolved runs unscoped
(require_existing=True only closes the missing-meta case — NULL-owner
meta rows still pass), so an internal caller acting for a different
owner could interrupt another owner's active run on a shared thread
while /join and /stream were already gated. The archive manifest and
download pair likewise leaked the other owner's delivered-file count
and a 200-vs-409 delivery oracle on NULL-owner threads (missing-meta
threads were already denied by require_existing=True).

All three routes now call _require_run_visible_to_scope; its docstring
records the extended coverage. NULL-owner-thread regression tests pin:
a mismatched internal owner gets 404 from cancel, manifest and archive
download, while the acting owner reaches the real conflict path (409 on
a terminal run) and reads the manifest (file_count 2).

* fix(gateway): tolerate state-less request stand-ins in the scope helpers

The new owner-isolation gate and _run_scope_user_id read request.state
directly, which crashed the FakeRequest-based unit suites for the run
events, workspace-changes and scope endpoints (backend-unit-tests shards
1/2/4 on #5484). Read the state object defensively first: a request
without state is simply not an internal caller, so those paths keep
their pre-gate semantics.

* fix(gateway): scope the thread token-usage aggregate by owner

Review follow-up on #5484 round 4: GET /{thread_id}/token-usage called
aggregate_tokens_by_thread(thread_id) with no user filter at all, so on
missing/NULL-owner threads an internal caller acting for owner A read
owner B's spend, model names, run count and (with include_active=true)
live activity; the NULL-owner variant reached browser sessions too.
build_context_usage's latest-model lookup was unfiltered as well.

aggregate_tokens_by_thread gains an optional user_id (mirroring
list_by_thread: explicit None = unfiltered, AUTO resolves the contextvar)
in the memory store, the SQL repository and the store base;
build_context_usage/_resolve_thread_model_name thread the scope through
the latest-run lookup; the token-usage endpoint passes
_run_scope_user_id's value. Established-ownership threads aggregate
unfiltered as before; shared/missing-meta threads narrow to the acting
identity. Stale helper-test comment reworded after the #5482 merge
adaptation.

* test(gateway): pin the unfiltered aggregate on established-ownership threads

Review follow-up on #5484 round 5: the established-ownership branch of
the token-usage scoping (store receives user_id=None) was the only
unpinned half of the contract — the round-4 call-assertions never set
app.state.thread_store, so their None came from the user-less stand-in
path. test_token_usage_unfiltered_on_established_ownership_for_
internal_callers seeds an established meta row plus runs stamped by two
different identities and asserts the totals fold (166 = 111 + 55);
together with the isolation tests it now catches both failure modes
(always-stamp narrowing and always-None leak).
2026-09-18 09:39:14 +08:00

183 lines
6.2 KiB
Python

"""Tests for thread-level token usage and context-window usage."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.gateway import context_usage
from app.gateway.routers import thread_runs
def _aggregate_result() -> dict:
return {
"total_tokens": 150,
"total_input_tokens": 90,
"total_output_tokens": 60,
"total_runs": 2,
"by_model": {"unknown": {"tokens": 150, "runs": 2}},
"by_caller": {
"lead_agent": 120,
"subagent": 25,
"middleware": 5,
},
}
def _make_run_store(*, model_name: str | None = None) -> MagicMock:
run_store = MagicMock()
run_store.aggregate_tokens_by_thread = AsyncMock(return_value=_aggregate_result())
runs = [{"model_name": model_name}] if model_name else []
run_store.list_by_thread = AsyncMock(return_value=runs)
return run_store
def _make_app(run_store: MagicMock):
app = make_authed_test_app()
app.include_router(thread_runs.router)
app.state.run_store = run_store
return app
def test_thread_token_usage_returns_stable_shape(monkeypatch: pytest.MonkeyPatch) -> None:
run_store = _make_run_store()
build_context_usage = AsyncMock(return_value=None)
monkeypatch.setattr(thread_runs, "build_context_usage", build_context_usage)
app = _make_app(run_store)
with TestClient(app) as client:
response = client.get("/api/threads/thread-1/token-usage")
assert response.status_code == 200
assert response.json() == {
"thread_id": "thread-1",
**_aggregate_result(),
"context_usage": None,
}
run_store.aggregate_tokens_by_thread.assert_awaited_once_with("thread-1", user_id=None)
build_context_usage.assert_awaited_once()
def test_thread_token_usage_can_include_active_runs(monkeypatch: pytest.MonkeyPatch) -> None:
run_store = _make_run_store()
build_context_usage = AsyncMock(return_value=None)
monkeypatch.setattr(thread_runs, "build_context_usage", build_context_usage)
app = _make_app(run_store)
with TestClient(app) as client:
response = client.get("/api/threads/thread-1/token-usage?include_active=true")
assert response.status_code == 200
run_store.aggregate_tokens_by_thread.assert_awaited_once_with("thread-1", include_active=True, user_id=None)
def test_thread_token_usage_serializes_context_percentage(monkeypatch: pytest.MonkeyPatch) -> None:
run_store = _make_run_store()
monkeypatch.setattr(
thread_runs,
"build_context_usage",
AsyncMock(
return_value={
"token_count": 350,
"max_context_tokens": 1000,
"percentage": 35.0,
}
),
)
app = _make_app(run_store)
with TestClient(app) as client:
response = client.get("/api/threads/thread-1/token-usage")
assert response.status_code == 200
assert response.json()["context_usage"] == {
"token_count": 350,
"max_context_tokens": 1000,
"percentage": 35.0,
}
def test_build_context_usage_payload_computes_percentage() -> None:
assert context_usage.build_context_usage_payload(token_count=350, max_context_tokens=1000) == {
"token_count": 350,
"max_context_tokens": 1000,
"percentage": 35.0,
}
def test_build_context_usage_payload_handles_unknown_capacity() -> None:
assert context_usage.build_context_usage_payload(token_count=350, max_context_tokens=None) == {
"token_count": 350,
"max_context_tokens": None,
"percentage": None,
}
@pytest.mark.asyncio
async def test_resolve_thread_model_prefers_latest_run() -> None:
run_store = _make_run_store(model_name="thread-model")
app_config = SimpleNamespace(models=[SimpleNamespace(name="fallback-model")])
assert await context_usage._resolve_thread_model_name(run_store, "thread-1", app_config) == "thread-model"
@pytest.mark.asyncio
async def test_resolve_thread_model_falls_back_to_first_configured_model() -> None:
run_store = _make_run_store()
app_config = SimpleNamespace(models=[SimpleNamespace(name="fallback-model")])
assert await context_usage._resolve_thread_model_name(run_store, "thread-1", app_config) == "fallback-model"
@pytest.mark.asyncio
async def test_build_context_usage_counts_materialized_messages(monkeypatch: pytest.MonkeyPatch) -> None:
messages = [SimpleNamespace(content="hello")]
snapshot = SimpleNamespace(values={"messages": messages})
accessor = SimpleNamespace(aget=AsyncMock(return_value=snapshot))
monkeypatch.setattr(
context_usage,
"build_thread_checkpoint_state_accessor",
AsyncMock(return_value=(accessor, {"configurable": {"thread_id": "thread-1"}})),
)
model_config = SimpleNamespace(context_window=1000)
app_config = SimpleNamespace(
models=[SimpleNamespace(name="fallback-model")],
get_model_config=lambda name: model_config if name == "thread-model" else None,
)
monkeypatch.setattr(context_usage, "get_config", lambda: app_config)
monkeypatch.setattr(context_usage, "_count_messages_approximately", lambda value: 250 if value == messages else 0)
result = await context_usage.build_context_usage(
request=SimpleNamespace(app=SimpleNamespace()),
thread_id="thread-1",
run_store=_make_run_store(model_name="thread-model"),
)
assert result == {
"token_count": 250,
"max_context_tokens": 1000,
"percentage": 25.0,
}
@pytest.mark.asyncio
async def test_build_context_usage_returns_none_when_checkpoint_read_fails(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
context_usage,
"build_thread_checkpoint_state_accessor",
AsyncMock(side_effect=RuntimeError("checkpoint unavailable")),
)
monkeypatch.setattr(context_usage, "get_config", lambda: SimpleNamespace())
result = await context_usage.build_context_usage(
request=SimpleNamespace(app=SimpleNamespace()),
thread_id="thread-1",
run_store=_make_run_store(),
)
assert result is None