deer-flow/backend/tests/test_thread_runs_internal_scope.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

781 lines
30 KiB
Python

"""Regression coverage for the runs/messages read endpoints' identity scoping (#5437).
Trusted internal callers are *authorized* as a synthetic internal user
(``system_role="internal"``) whose id is ``"default"`` or the
``make_safe_user_id``-normalized owner, while ``start_run`` stamps run rows
with the raw trusted-owner value. Filtering the reads by the authorization
identity therefore never matches the persisted rows. The read endpoints must
skip the per-user filter for internal callers — thread visibility is already
authorized by ``@require_permission(..., owner_check=True)`` — and keep it for
browser/API sessions.
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from uuid import UUID
import pytest
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.testclient import TestClient
from langgraph.store.memory import InMemoryStore
from starlette.middleware.base import BaseHTTPMiddleware
from app.gateway.auth.models import User
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION
from app.gateway.authz import AuthContext, Permissions
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, get_internal_user
from app.gateway.routers import thread_runs
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
THREAD_ID = "thread-scope"
BROWSER_USER_ID = UUID("00000000-0000-0000-0000-00000000000a")
# A lossy trusted-owner value: make_safe_user_id normalizes it to
# "feishu-owner-777-<digest>", which can never equal the raw value stamped on
# the run row — the exact mismatch class reported in #5437.
OWNER_RAW = "feishu:owner-777"
RUN_BROWSER = "run-browser-row"
RUN_OWNER = "run-owner-row"
_STUB_PERMISSIONS: list[str] = [
Permissions.THREADS_READ,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
]
class _ScopeAuthMiddleware(BaseHTTPMiddleware):
"""Stamp the same state trio production ``AuthMiddleware`` stamps."""
def __init__(self, app, *, user, auth_source: str) -> None:
super().__init__(app)
self._user = user
self._auth_source = auth_source
async def dispatch(self, request: Request, call_next) -> Response:
request.state.user = self._user
request.state.auth_source = self._auth_source
request.state.auth = AuthContext(user=self._user, permissions=list(_STUB_PERMISSIONS))
return await call_next(request)
class _PermissiveThreadStore:
"""Stands in for the thread store behind ``owner_check=True``.
The existing scope tests exercise the established-ownership path, so
``get`` reports an existing, owner-established meta row.
"""
async def check_access(self, _thread_id: str, _user_id: str, *, require_existing: bool = False) -> bool:
return True
async def get(self, _thread_id: str, *, user_id: str | None | object = None) -> dict | None:
return {"thread_id": THREAD_ID, "user_id": "established-owner"}
class _RecordingRunStore(MemoryRunStore):
"""Records the per-user filter identity each read resolves to.
``MemoryRunEventStore.list_messages`` ignores ``user_id`` (only the SQL
backends honor it), so the runs store is where the resolved filter id is
observable in-memory: hidden-run lookups and turn-duration injection both
flow through ``list_by_thread``/``get`` with the endpoint's filter id.
"""
def __init__(self) -> None:
super().__init__()
self.list_by_thread_user_ids: list[str | None] = []
self.get_user_ids: list[str | None] = []
async def list_by_thread(self, thread_id, *, user_id=None, **kwargs):
self.list_by_thread_user_ids.append(user_id)
return await super().list_by_thread(thread_id, user_id=user_id, **kwargs)
async def get(self, run_id, *, user_id=None, **kwargs):
self.get_user_ids.append(user_id)
return await super().get(run_id, user_id=user_id, **kwargs)
class _RecordingFeedbackRepo:
"""Records the per-user identity the feedback queries are scoped with."""
def __init__(self) -> None:
self.list_by_thread_user_ids: list[str | None] = []
self.list_by_run_ids_user_ids: list[str | None] = []
async def list_by_thread_grouped(self, thread_id, *, user_id=None):
self.list_by_thread_user_ids.append(user_id)
return {}
async def list_by_run_ids(self, thread_id, run_ids, *, user_id=None):
self.list_by_run_ids_user_ids.append(user_id)
return {}
def _browser_user() -> User:
return User(id=BROWSER_USER_ID, email="scope-test@example.com", password_hash="x", system_role="user")
def _internal_user(owner_raw: str | None):
# Mirrors AuthMiddleware + get_internal_user: the synthetic internal user
# carries the safe-spelled owner id, or "default" without an owner header.
return get_internal_user(owner_user_id=owner_raw)
def _seed_run(store: MemoryRunStore, run_id: str, *, user_id: str | None, status: str = "success") -> None:
asyncio.run(store.put(run_id, thread_id=THREAD_ID, user_id=user_id, status=status))
def _seed_message(event_store: MemoryRunEventStore, run_id: str, message_id: str) -> None:
asyncio.run(
event_store.put(
thread_id=THREAD_ID,
run_id=run_id,
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": message_id, "content": message_id, "additional_kwargs": {}},
metadata={},
)
)
def _make_app(
*,
user,
auth_source: str,
run_store: MemoryRunStore,
event_store: MemoryRunEventStore | None = None,
feedback_repo: _RecordingFeedbackRepo | None = None,
thread_store=None,
) -> TestClient:
app = FastAPI()
app.add_middleware(_ScopeAuthMiddleware, user=user, auth_source=auth_source)
app.state.thread_store = thread_store if thread_store is not None else _PermissiveThreadStore()
app.state.run_store = run_store
app.state.run_manager = RunManager(store=run_store)
if event_store is not None:
app.state.run_event_store = event_store
if feedback_repo is not None:
app.state.feedback_repo = feedback_repo
app.include_router(thread_runs.router)
return TestClient(app)
@pytest.fixture()
def mixed_owner_store() -> MemoryRunStore:
store = MemoryRunStore()
_seed_run(store, RUN_BROWSER, user_id=str(BROWSER_USER_ID))
_seed_run(store, RUN_OWNER, user_id=OWNER_RAW)
return store
def test_internal_caller_lists_owner_stamped_runs(mixed_owner_store: MemoryRunStore) -> None:
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=mixed_owner_store,
)
with client:
response = client.get(
f"/api/threads/{THREAD_ID}/runs",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert response.status_code == 200
assert {row["run_id"] for row in response.json()} == {RUN_BROWSER, RUN_OWNER}
def test_internal_caller_get_run_owner_stamped(mixed_owner_store: MemoryRunStore) -> None:
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=mixed_owner_store,
)
with client:
response = client.get(
f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert response.status_code == 200
assert response.json()["run_id"] == RUN_OWNER
def test_internal_caller_runs_page_owner_stamped(mixed_owner_store: MemoryRunStore) -> None:
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=mixed_owner_store,
)
with client:
response = client.get(
f"/api/threads/{THREAD_ID}/runs/page",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert response.status_code == 200
assert {row["run_id"] for row in response.json()["data"]} == {RUN_BROWSER, RUN_OWNER}
assert response.json()["has_more"] is False
def test_internal_caller_without_owner_header_sees_authorized_thread_runs(mixed_owner_store: MemoryRunStore) -> None:
"""No owner header ⇒ synthetic id "default", which matches nothing either.
The thread is authorized via owner_check, so its runs stay listable.
"""
client = _make_app(
user=_internal_user(None),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=mixed_owner_store,
)
with client:
response = client.get(f"/api/threads/{THREAD_ID}/runs")
assert response.status_code == 200
assert {row["run_id"] for row in response.json()} == {RUN_BROWSER, RUN_OWNER}
def test_browser_session_keeps_per_user_filter(mixed_owner_store: MemoryRunStore) -> None:
"""Browser sessions keep filtering by their own data identity."""
client = _make_app(
user=_browser_user(),
auth_source=AUTH_SOURCE_SESSION,
run_store=mixed_owner_store,
)
with client:
listed = client.get(f"/api/threads/{THREAD_ID}/runs")
cross_user = client.get(f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}")
assert listed.status_code == 200
assert [row["run_id"] for row in listed.json()] == [RUN_BROWSER]
assert cross_user.status_code == 404
def test_internal_caller_messages_skip_per_user_filter(mixed_owner_store: MemoryRunStore) -> None:
"""Internal callers read the authorized thread's messages unfiltered.
The observable filter identity is what reaches the scoped queries — the
runs store (hidden-run lookups, turn durations) and the feedback repo —
``None`` for internal callers.
"""
event_store = MemoryRunEventStore()
_seed_message(event_store, RUN_OWNER, "msg-owner")
_seed_message(event_store, RUN_BROWSER, "msg-browser")
run_store = _RecordingRunStore()
for run_id, user_id in ((RUN_BROWSER, str(BROWSER_USER_ID)), (RUN_OWNER, OWNER_RAW)):
_seed_run(run_store, run_id, user_id=user_id)
feedback_repo = _RecordingFeedbackRepo()
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
event_store=event_store,
feedback_repo=feedback_repo,
)
with client:
response = client.get(
f"/api/threads/{THREAD_ID}/messages",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
page = client.get(
f"/api/threads/{THREAD_ID}/messages/page",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert response.status_code == 200
assert {row["content"]["id"] for row in response.json()} == {"msg-owner", "msg-browser"}
assert page.status_code == 200
assert {row["content"]["id"] for row in page.json()["data"]} == {"msg-owner", "msg-browser"}
assert run_store.list_by_thread_user_ids and all(uid is None for uid in run_store.list_by_thread_user_ids)
assert feedback_repo.list_by_thread_user_ids == [None]
assert feedback_repo.list_by_run_ids_user_ids == [None]
def test_browser_session_messages_keep_per_user_filter(mixed_owner_store: MemoryRunStore) -> None:
"""Browser sessions keep passing their own id to the messages pipeline."""
event_store = MemoryRunEventStore()
_seed_message(event_store, RUN_OWNER, "msg-owner")
_seed_message(event_store, RUN_BROWSER, "msg-browser")
run_store = _RecordingRunStore()
for run_id, user_id in ((RUN_BROWSER, str(BROWSER_USER_ID)), (RUN_OWNER, OWNER_RAW)):
_seed_run(run_store, run_id, user_id=user_id)
feedback_repo = _RecordingFeedbackRepo()
client = _make_app(
user=_browser_user(),
auth_source=AUTH_SOURCE_SESSION,
run_store=run_store,
event_store=event_store,
feedback_repo=feedback_repo,
)
with client:
response = client.get(f"/api/threads/{THREAD_ID}/messages")
assert response.status_code == 200
assert {row["content"]["id"] for row in response.json()} == {"msg-owner", "msg-browser"}
assert run_store.list_by_thread_user_ids and all(uid == str(BROWSER_USER_ID) for uid in run_store.list_by_thread_user_ids)
assert feedback_repo.list_by_thread_user_ids == [str(BROWSER_USER_ID)]
# ---------------------------------------------------------------------------
# owner isolation on threads without established metadata (#5448 review P1)
# ---------------------------------------------------------------------------
def test_missing_thread_meta_keeps_owner_isolation_for_internal_callers() -> None:
"""owner_check also authorizes missing-meta (legacy shared) threads.
There, unfiltered reads would expose other users' persisted runs to the
acting owner's internal caller, so the raw trusted owner stays the filter
— the exact value ``start_run`` stamps on run rows (#5448 review P1).
"""
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no metadata row at all
run_store = _RecordingRunStore()
_seed_run(run_store, "run-other-user", user_id=str(BROWSER_USER_ID), status="success")
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
thread_store=thread_store,
)
with client:
listed = client.get(
f"/api/threads/{THREAD_ID}/runs",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
page = client.get(
f"/api/threads/{THREAD_ID}/runs/page",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
single = client.get(
f"/api/threads/{THREAD_ID}/runs/run-other-user",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert listed.status_code == 200
assert listed.json() == []
assert page.status_code == 200
assert page.json()["data"] == []
assert single.status_code == 404
# The acting owner's own raw-stamped runs remain visible: seed one and
# confirm it comes back through the same endpoints.
owned_store = _RecordingRunStore()
_seed_run(owned_store, "run-own-owner-stamp", user_id=OWNER_RAW, status="success")
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=owned_store,
thread_store=thread_store,
)
with client:
own = client.get(
f"/api/threads/{THREAD_ID}/runs/run-own-owner-stamp",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert own.status_code == 200
assert own.json()["run_id"] == "run-own-owner-stamp"
def test_null_owner_thread_meta_keeps_owner_isolation_for_internal_callers() -> None:
"""NULL-owner meta rows (shared/pre-auth data) isolate by raw owner too."""
thread_store = MemoryThreadMetaStore(InMemoryStore())
asyncio.run(
thread_store.create(
THREAD_ID,
assistant_id=None,
user_id=None, # shared / pre-auth: meta row exists with NULL owner
)
)
run_store = _RecordingRunStore()
_seed_run(run_store, "run-other-user", user_id=str(BROWSER_USER_ID), status="success")
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
thread_store=thread_store,
)
with client:
listed = client.get(
f"/api/threads/{THREAD_ID}/runs",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
single = client.get(
f"/api/threads/{THREAD_ID}/runs/run-other-user",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert listed.status_code == 200
assert listed.json() == []
assert single.status_code == 404
def test_established_ownership_still_reads_thread_runs_unfiltered(mixed_owner_store: MemoryRunStore) -> None:
"""Established meta ownership keeps the #5437 unfiltered-read behavior."""
thread_store = MemoryThreadMetaStore(InMemoryStore())
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=OWNER_RAW))
run_store = _RecordingRunStore()
_seed_run(run_store, RUN_OWNER, user_id=OWNER_RAW, status="success")
_seed_run(run_store, "run-legacy-default", user_id="default", status="success")
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
thread_store=thread_store,
)
with client:
response = client.get(
f"/api/threads/{THREAD_ID}/runs",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert response.status_code == 200
assert {row["run_id"] for row in response.json()} == {RUN_OWNER, "run-legacy-default"}
def test_ownerless_internal_caller_default_filter_on_missing_meta() -> None:
"""Without an owner header the synthetic "default" identity is the filter.
Pins the owner-less fallback branch of ``_run_scope_user_id``: a run
stamped with another owner's raw id stays hidden on missing-meta threads.
"""
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
run_store = _RecordingRunStore()
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
client = _make_app(
user=_internal_user(None),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
thread_store=thread_store,
)
with client:
response = client.get(f"/api/threads/{THREAD_ID}/runs")
assert response.status_code == 200
assert response.json() == []
def test_subresource_reads_stay_owner_isolated_without_meta() -> None:
"""Run-scoped sub-resources must respect the acting owner's stamp.
These reads query by ``(thread_id, run_id)`` with no per-user filter of
their own; on missing-meta threads an internal caller acting for owner A
could otherwise read owner B's run content by id (#5448 review P1
follow-up).
"""
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
run_store = _RecordingRunStore()
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
event_store = MemoryRunEventStore()
_seed_message(event_store, "run-owner-777", "msg-owner-run")
stranger_headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: "feishu:owner-999"}
owner_headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW}
base = f"/api/threads/{THREAD_ID}/runs/run-owner-777"
stranger = _make_app(
user=_internal_user("feishu:owner-999"),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
event_store=event_store,
thread_store=thread_store,
)
owner_client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
event_store=event_store,
thread_store=thread_store,
)
with stranger:
assert stranger.get(base + "/messages", headers=stranger_headers).status_code == 404
assert stranger.get(base + "/events", headers=stranger_headers).status_code == 404
assert stranger.get(base + "/workspace-changes", headers=stranger_headers).status_code == 404
assert stranger.get(base + "/join", headers=stranger_headers).status_code == 404
with owner_client:
messages = owner_client.get(base + "/messages", headers=owner_headers)
events = owner_client.get(base + "/events", headers=owner_headers)
assert messages.status_code == 200
assert [row["content"]["id"] for row in messages.json()["data"]] == ["msg-owner-run"]
assert events.status_code == 200
assert any(event.get("run_id") == "run-owner-777" for event in events.json())
def test_null_owner_thread_gates_cancel_and_archive_for_internal_callers() -> None:
"""NULL-owner meta rows gate POST /cancel and the archive pair too.
The round-2 findings: cancel resolved runs unscoped (an interrupt-vs-join
inconsistency) and the archive manifest leaked the other owner's
delivered-file count plus a 200-vs-409 delivery oracle on shared threads.
"""
thread_store = MemoryThreadMetaStore(InMemoryStore())
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=None))
run_store = _RecordingRunStore()
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
event_store = MemoryRunEventStore()
stranger_headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: "feishu:owner-999"}
stranger = _make_app(
user=_internal_user("feishu:owner-999"),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
event_store=event_store,
thread_store=thread_store,
)
with stranger:
cancel = stranger.post(
f"/api/threads/{THREAD_ID}/runs/run-owner-777/cancel?action=interrupt",
headers=stranger_headers,
)
manifest = stranger.get(
f"/api/threads/{THREAD_ID}/runs/run-owner-777/artifacts/archive",
headers=stranger_headers,
)
archive = stranger.post(
f"/api/threads/{THREAD_ID}/runs/run-owner-777/artifacts/archive",
headers=stranger_headers,
)
assert cancel.status_code == 404
assert manifest.status_code == 404
assert archive.status_code == 404
def test_null_owner_thread_matching_owner_cancels_and_reads_manifest() -> None:
"""The acting owner keeps cancel and archive access on shared threads."""
thread_store = MemoryThreadMetaStore(InMemoryStore())
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=None))
run_store = _RecordingRunStore()
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
event_store = MemoryRunEventStore()
asyncio.run(
event_store.put(
thread_id=THREAD_ID,
run_id="run-owner-777",
event_type="run.delivery",
category="outputs",
content={"presented": 2, "by_tool": {"present_files": ["/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.txt"]}},
)
)
owner_client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
event_store=event_store,
thread_store=thread_store,
)
with owner_client:
manifest = owner_client.get(
f"/api/threads/{THREAD_ID}/runs/run-owner-777/artifacts/archive",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
cancel = owner_client.post(
f"/api/threads/{THREAD_ID}/runs/run-owner-777/cancel?action=interrupt",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert manifest.status_code == 200
assert manifest.json() == {"file_count": 2}
# A terminal run cannot be cancelled again: the acting owner reaches the
# real conflict path instead of a 404 anti-enumeration answer.
assert cancel.status_code == 409
# edit/regenerate helper fallback paths (#5482)
# ---------------------------------------------------------------------------
def _helper_request(*, user, auth_source: str, run_store, event_store, owner_header: str | None = None):
"""Minimal Request stand-in: the helpers touch state, app.state and headers."""
app_state = SimpleNamespace(run_manager=RunManager(store=run_store), run_event_store=event_store)
headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: owner_header} if owner_header else {}
return SimpleNamespace(
state=SimpleNamespace(user=user, auth_source=auth_source),
app=SimpleNamespace(state=app_state),
headers=headers,
)
def test_helper_fallback_paths_resolve_internal_caller_runs() -> None:
"""The edit/regenerate helper fallbacks must use the data identity (#5482)."""
store = _RecordingRunStore()
_seed_run(store, RUN_OWNER, user_id=OWNER_RAW, status="interrupted")
_seed_run(store, RUN_BROWSER, user_id=str(BROWSER_USER_ID), status="success")
request = _helper_request(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=store,
event_store=MemoryRunEventStore(),
owner_header=OWNER_RAW,
)
# The owner-stamped interrupted run resolves through the raw owner stamp.
interrupted = asyncio.run(thread_runs._find_interrupted_target_run_id(THREAD_ID, {"additional_kwargs": {"run_id": RUN_OWNER}}, request))
assert interrupted == RUN_OWNER
assert store.get_user_ids[-1] == OWNER_RAW
# An interrupted run is not an editable source run, but the lookup itself
# must have reached it (409 for status, not for a missing record).
with pytest.raises(HTTPException) as exc:
asyncio.run(thread_runs._require_successful_source_run(THREAD_ID, RUN_OWNER, request))
assert exc.value.status_code == 409
assert "successful" in exc.value.detail
assert store.get_user_ids[-1] == OWNER_RAW
# Fallback scan without any event-store or kwargs anchors still scans the
# authorized thread through the acting owner's raw-stamp scope (and 409s
# on the miss).
with pytest.raises(HTTPException) as exc2:
asyncio.run(
thread_runs._find_target_run_id(
THREAD_ID,
"missing-message",
{"content": "unmatched"},
{"additional_kwargs": {}},
request,
)
)
assert exc2.value.status_code == 409
assert store.list_by_thread_user_ids and store.list_by_thread_user_ids[-1] == OWNER_RAW
def test_helper_fallback_paths_keep_per_user_filter_for_browser_sessions() -> None:
"""Browser sessions keep the per-user filter in the helper fallbacks."""
store = _RecordingRunStore()
_seed_run(store, RUN_OWNER, user_id=OWNER_RAW, status="interrupted")
_seed_run(store, RUN_BROWSER, user_id=str(BROWSER_USER_ID), status="success")
request = _helper_request(
user=_browser_user(),
auth_source=AUTH_SOURCE_SESSION,
run_store=store,
event_store=MemoryRunEventStore(),
)
# The owner-stamped run is invisible under the browser user's filter.
assert asyncio.run(thread_runs._find_interrupted_target_run_id(THREAD_ID, {"additional_kwargs": {"run_id": RUN_OWNER}}, request)) is None
assert store.get_user_ids[-1] == str(BROWSER_USER_ID)
# Their own successful run still resolves, and a cross-user one 409s.
record = asyncio.run(thread_runs._require_successful_source_run(THREAD_ID, RUN_BROWSER, request))
assert record.run_id == RUN_BROWSER
with pytest.raises(HTTPException) as exc:
asyncio.run(thread_runs._require_successful_source_run(THREAD_ID, RUN_OWNER, request))
assert exc.value.status_code == 409
assert store.get_user_ids[-1] == str(BROWSER_USER_ID)
def test_token_usage_isolated_without_meta_for_internal_callers() -> None:
"""Token-usage aggregate honors the acting owner's raw stamp (#5484 r4)."""
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
run_store = _RecordingRunStore()
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
_seed_run(run_store, "run-other-user", user_id=str(BROWSER_USER_ID), status="success")
run_store._runs["run-owner-777"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 111}}
run_store._runs["run-owner-777"]["total_tokens"] = 111
run_store._runs["run-other-user"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 999}}
run_store._runs["run-other-user"]["total_tokens"] = 999
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
thread_store=thread_store,
)
with client:
response = client.get(
f"/api/threads/{THREAD_ID}/token-usage",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert response.status_code == 200
body = response.json()
assert body["total_tokens"] == 111
assert body["total_runs"] == 1
def test_token_usage_narrowed_for_browser_sessions_without_meta() -> None:
"""Browser sessions on shared threads see only their own spend too."""
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
run_store = _RecordingRunStore()
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
_seed_run(run_store, "run-browser", user_id=str(BROWSER_USER_ID), status="success")
run_store._runs["run-owner-777"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 111}}
run_store._runs["run-owner-777"]["total_tokens"] = 111
run_store._runs["run-browser"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 222}}
run_store._runs["run-browser"]["total_tokens"] = 222
client = _make_app(
user=_browser_user(),
auth_source=AUTH_SOURCE_SESSION,
run_store=run_store,
thread_store=thread_store,
)
with client:
response = client.get(f"/api/threads/{THREAD_ID}/token-usage")
assert response.status_code == 200
assert response.json()["total_tokens"] == 222
def test_token_usage_unfiltered_on_established_ownership_for_internal_callers() -> None:
"""Established meta ownership keeps the unfiltered aggregate.
Pins the other half of the scoping contract: on an established thread the
internal caller's aggregate folds runs stamped by different identities
(the store must receive ``user_id=None``), mirroring
``test_established_ownership_still_reads_thread_runs_unfiltered``.
"""
thread_store = MemoryThreadMetaStore(InMemoryStore())
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=OWNER_RAW))
run_store = _RecordingRunStore()
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
_seed_run(run_store, "run-legacy-default", user_id="default", status="success")
run_store._runs["run-owner-777"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 111}}
run_store._runs["run-owner-777"]["total_tokens"] = 111
run_store._runs["run-legacy-default"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 55}}
run_store._runs["run-legacy-default"]["total_tokens"] = 55
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
thread_store=thread_store,
)
with client:
response = client.get(
f"/api/threads/{THREAD_ID}/token-usage",
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
)
assert response.status_code == 200
body = response.json()
assert body["total_tokens"] == 166 # both stamps fold when ownership is established