Files
turnstone/tests/test_server_attachments_endpoints.py
Patrick Buckley 480a1426b3 Fail-closed history-commit handoff (#1005)
* fix(session): fail-closed history-commit handoff (#981)

The deleted-workstream discovery is now a terminal, ws_id-keyed latch:
keyed conversation commits refuse admission once the durable parent is
gone (convergence finalizers and force-abandon are exempt), history
handoff refuses to mint a proof token so /history fails closed with a
503 instead of silently wiping the pane, and the SSE stream carries a
workstream_gone resync reason. Discarded commits leave a forensic log
of commit keys and roles, never content.

Conversation rows gain a commit_key (migration 071): keyed saves are
idempotent under retry, validated against the full commit identity, and
refused when they would cross a workstream deletion. The prune orphan
category now requires a NULL alias plus a two-hour updated grace, with
cutoffs computed at discovery time and carried into both dialects'
rechecks.

The mid-turn interjection queue is owner-partitioned with no per-site
mode flags: pops take the acting principal's and unowned rows, other
participants' rows are structurally retained, and enforcement lives at
queue admission plus the shared before_spawn gates. The retraction
ledger is bounded by open pop windows: pops open a window atomically
with the queue delete, restores close their ids atomically with the
ledger consume, every other exit closes through one helper, and misses
for unheld ids record nothing. The workstream-gone latch refuses
unattended wakes at all three gates (watcher spawn, claim, delivery
pre-pop), and the retry dispatcher regained its pre-envelope
cancel/error convergence net.

Persistence-state reporting derives through the session bound to each
UI instead of a registry lookup by id that failed open to healthy
during tombstone retention. The dashboard roster no longer re-inserts
ghost entries from trailing activity events, the history tool-outcome
scan tolerates interleaved non-turn rows, and the shared
handoff-deadline handle owns its own retirement.

Single-sourced across call sites: keyed-commit row values, attachment
save wrappers, tail-truncation and conflict-resolution bodies for both
storage dialects; worker-slot lifecycle field sets; the direct-commit
admission frame; queued-row layout accessors; the string-aware comment
stripper shared by every JS harness suite.

Refs #981 #964

* fix(session): sweep handoff fixes to their sibling surfaces

The interactive replay loop treated a system row as a tool-batch
boundary, so every tool result after an interleaved row vanished from
that pane while the coordinator rendered the same history correctly.
Only a conversational turn ends the batch window now, matching the
shared outcome index.

Accepted user turns clear the composer's attachment chips on the same
viewer policy that settles optimistic bubbles rather than on having
matched a local bubble, so a workstream created with an upload no
longer keeps a chip for an attachment the create dispatch already
consumed. The coordinator's raced-Stop arm emits the stream-end hook it
inherits alongside the idle state, leaving no unfinalized bubble or
unflushed tool output. Ending a session surfaces a failure toast when
the request never lands or answers with a non-JSON body.

The per-second persistence reconcile now probes each session without
blocking: a workstream whose generation and handoff locks are held is
skipped until the next pass instead of contending the locks every
commit needs. The one-shot repair that gates workstream creation at
capacity keeps a definite probe — it has no next pass, and the sessions
likeliest to be contended are the ones whose unresolved journals
emptied its candidate list.

Single-sourced: the attachment lane builds its conversation row through
the shared commit-identity builder; the ordinary worker exit releases
its slot through the lifecycle owner; both operator surfaces snapshot
their counters through one non-consuming helper; the replay preamble
loses its per-kind wrappers and its config hook; the browser harness
suites share one brace walker; and each in-flight history attempt is
one record carrying both its abort controller and its deadline.

Refs #981 #964
2026-08-11 04:18:36 -07:00

1501 lines
59 KiB
Python

"""HTTP endpoint tests for workstream attachments.
Uses Starlette's TestClient against an in-process app with a mocked
SessionManager. Exercises: upload happy path, size/mime rejection,
pending-list, GET /content, DELETE, auth isolation, and the extended
/api/send handler with both explicit and auto-consumed attachment ids.
"""
from __future__ import annotations
import queue
import threading
from unittest.mock import MagicMock
import pytest
from starlette.testclient import TestClient
# Magic-byte-valid 1x1 PNG
PNG_1x1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82"
)
# Magic-byte-valid minimal WAV (RIFF....WAVE) for audio-kind uploads.
WAV_12 = b"RIFF\x24\x00\x00\x00WAVEfmt " + b"\x00" * 16
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _make_jwt(user_id: str) -> str:
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
return create_jwt(
user_id=user_id,
scopes=frozenset({"read", "write"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
@pytest.fixture
def app_client(tmp_path):
"""Spin up an in-process Starlette app with a mocked SessionManager
and a fresh SQLite storage."""
import sqlalchemy as sa
import turnstone.server as srv_mod
from turnstone.core.memory import register_workstream
from turnstone.core.metrics import MetricsCollector
from turnstone.core.storage import init_storage, reset_storage
from turnstone.core.storage._registry import get_storage
from turnstone.core.storage._schema import workstreams as ws_tbl
# Fresh DB per test
db_path = tmp_path / "test.db"
reset_storage()
init_storage("sqlite", path=str(db_path), run_migrations=False)
srv_mod._metrics = MetricsCollector()
srv_mod._metrics.model = "test-model"
# Register two workstreams with different owners
register_workstream("ws-A", name="A")
register_workstream("ws-B", name="B")
# Seed user_id on the rows so ownership checks take the scoped path
with get_storage()._conn() as conn:
conn.execute(sa.update(ws_tbl).where(ws_tbl.c.ws_id == "ws-A").values(user_id="userA"))
conn.execute(sa.update(ws_tbl).where(ws_tbl.c.ws_id == "ws-B").values(user_id="userB"))
conn.commit()
# SessionManager mock returns None for get(); send endpoint handles that,
# but we bypass send to focus on attachments. get() returning a mock is
# only needed for /api/send; upload/list/content/delete don't use mgr.
mock_mgr = MagicMock()
mock_mgr.get.return_value = None
mock_mgr.list_all.return_value = []
mock_mgr.max_active = 10
app = srv_mod.create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
jwt_secret=_TEST_JWT_SECRET,
)
# Pending uploads live in the process-global per-node buffer now; clear it
# so staged uploads can't leak across tests.
from turnstone.core.attachment_buffer import get_attachment_buffer
get_attachment_buffer().clear()
client = TestClient(app, raise_server_exceptions=False)
try:
yield client, mock_mgr
finally:
client.close()
get_attachment_buffer().clear()
reset_storage()
def _auth(user: str) -> dict[str, str]:
return {"Authorization": f"Bearer {_make_jwt(user)}"}
def _harden_ws_mock(ws) -> None:
"""Neutralize every truthy-Mock trap the /send dispatch path reads.
A bare ``MagicMock()`` auto-creates truthy attributes and callables,
which the route misreads: a truthy ``_closed`` makes ``send()``
refuse; truthy ``_pending_sends``/``_pending_drain`` (and a truthy
``send_barrier_active()`` result — the route consults the barrier as
a METHOD) defer every send behind a phantom order barrier. Every
NEW Workstream field the dispatch path reads gets added HERE, once —
not appended to each fixture (missing one copy made that fixture's
sends defer/hang with an error pointing nowhere near the cause).
Deliberately does NOT set ``_worker_running``: fixtures choose that
per scenario (one even relies on the truthy auto-Mock to force the
queue path).
"""
ws._closed = False
ws._pending_sends = []
ws._pending_drain = None
ws._worker_principal_id = ""
ws.send_barrier_active = lambda: False
ws._lock = threading.RLock()
# ---------------------------------------------------------------------------
# Upload
# ---------------------------------------------------------------------------
class TestUploadHappyPath:
def test_upload_png(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("tiny.png", PNG_1x1, "image/png")},
headers=_auth("userA"),
)
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "image"
assert body["mime_type"] == "image/png"
assert body["size_bytes"] == len(PNG_1x1)
assert body["filename"] == "tiny.png"
assert body["attachment_id"]
def test_upload_markdown_text(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("notes.md", b"# hi\n", "text/markdown")},
headers=_auth("userA"),
)
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "text"
assert body["mime_type"] == "text/markdown"
def test_upload_by_extension_when_mime_missing(self, app_client):
client, _ = app_client
# Send an application/octet-stream body — only extension should save it.
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("script.py", b"print('hi')\n", "application/octet-stream")},
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.json()["kind"] == "text"
class TestUploadRejections:
def test_oversize_image_rejected(self, app_client):
client, _ = app_client
# 5 MB PNG header followed by junk — triggers the 4 MiB cap
big = PNG_1x1 + b"\x00" * (5 * 1024 * 1024)
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("big.png", big, "image/png")},
headers=_auth("userA"),
)
assert resp.status_code == 413
assert resp.json().get("code") == "too_large"
def test_oversize_text_rejected(self, app_client):
client, _ = app_client
big = b"x" * (600 * 1024) # 600 KiB > 512 KiB text cap
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("big.md", big, "text/markdown")},
headers=_auth("userA"),
)
assert resp.status_code == 413
def test_unsupported_mime_rejected(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("blob.bin", b"\x00\x01\x02\x03", "application/octet-stream")},
headers=_auth("userA"),
)
assert resp.status_code == 400
assert "code" in resp.json()
def test_non_utf8_text_rejected(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("bad.txt", b"\xff\xfe\x00\x00", "text/plain")},
headers=_auth("userA"),
)
assert resp.status_code == 400
assert "UTF-8" in resp.json()["error"]
def test_empty_file_rejected(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("empty.md", b"", "text/markdown")},
headers=_auth("userA"),
)
assert resp.status_code == 400
def test_missing_file_field_400(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
data={"not_file": "x"},
headers=_auth("userA"),
)
assert resp.status_code == 400
def test_unknown_workstream_404(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-DOES-NOT-EXIST/attachments",
files={"file": ("x.md", b"x", "text/markdown")},
headers=_auth("userA"),
)
assert resp.status_code == 404
def test_any_caller_can_attach_to_workstream(self, app_client):
# Trusted-team model: attaching to any workstream is gated on
# scope auth, not ownership. The attachment is filed under
# the ws's persisted owner so existing storage shape holds.
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-B/attachments",
files={"file": ("x.md", b"x", "text/markdown")},
headers=_auth("userA"),
)
assert resp.status_code == 200
# (The per-user pending-upload cap was removed with the content-addressing
# cutover — pending uploads live in the per-node buffer, bounded by its own
# size/TTL ceilings rather than a per-(ws,user) count. The cap tests that
# lived here are gone.)
# ---------------------------------------------------------------------------
# List / Get content / Delete
# ---------------------------------------------------------------------------
def _upload(client, ws_id: str, user: str, filename: str, data: bytes, mime: str) -> str:
resp = client.post(
f"/v1/api/workstreams/{ws_id}/attachments",
files={"file": (filename, data, mime)},
headers=_auth(user),
)
assert resp.status_code == 200, resp.text
return resp.json()["attachment_id"]
class TestListAttachments:
def test_list_pending_returns_metadata_only(self, app_client):
client, _ = app_client
_upload(client, "ws-A", "userA", "a.md", b"A", "text/markdown")
_upload(client, "ws-A", "userA", "b.md", b"B", "text/markdown")
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
assert resp.status_code == 200
atts = resp.json()["attachments"]
assert len(atts) == 2
# No content bytes in list payload
assert all("content" not in a for a in atts)
assert {a["filename"] for a in atts} == {"a.md", "b.md"}
def test_list_visible_cluster_wide(self, app_client):
# Trusted-team visibility: any authenticated caller can list
# the attachments on any workstream. Attachments are filed
# under the ws's owner uid so a cross-caller lister still sees
# the owner's pending uploads.
client, _ = app_client
_upload(client, "ws-A", "userA", "mine.md", b"mine", "text/markdown")
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userB"))
assert resp.status_code == 200
atts = resp.json()["attachments"]
assert {a["filename"] for a in atts} == {"mine.md"}
class TestGetContent:
def test_get_content_returns_bytes_with_mime(self, app_client):
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.png", PNG_1x1, "image/png")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/content",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
assert resp.content == PNG_1x1
# Defense-in-depth headers
assert resp.headers.get("x-content-type-options") == "nosniff"
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
assert resp.headers.get("content-disposition", "").startswith("inline;")
def test_get_content_non_latin1_filename_does_not_500(self, app_client):
# Starlette encodes header values as latin-1 and raises on anything
# else; an uploaded filename with CJK / em dashes must fold to an
# ASCII-safe Content-Disposition rather than 500 the serving route.
# Mirrors preview_response_headers' latin-1 fold.
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "文書 — v1.md", b"x", "text/markdown")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/content",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.content == b"x"
# Non-ASCII folded to '?', ASCII kept — pinning the value proves the
# fold actually ran and the header is latin-1 clean (all codepoints
# < 0x80), not merely that the route didn't crash.
assert resp.headers["content-disposition"] == 'inline; filename="?? ? v1.md"'
def test_get_content_forces_text_plain_for_text_kinds(self, app_client):
# Uploading an HTML-ish file as text/html must NOT be served back
# with Content-Type: text/html from our origin (XSS vector).
client, _ = app_client
aid = _upload(
client,
"ws-A",
"userA",
"evil.html",
b"<script>alert(1)</script>",
"text/html",
)
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/content",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/plain")
assert resp.headers.get("x-content-type-options") == "nosniff"
def test_get_content_visible_cluster_wide(self, app_client):
# Trusted-team visibility: any authenticated caller can fetch
# the content of an attachment on any workstream. Attachments
# are keyed by the ws's persisted owner uid so userB still
# resolves userA's blob via _require_ws_access's owner return.
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.md", b"x", "text/markdown")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/content",
headers=_auth("userB"),
)
assert resp.status_code == 200
assert resp.content == b"x"
def test_get_content_cross_workstream_id_404(self, app_client):
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.md", b"x", "text/markdown")
# Request via ws-B (owned by userB) with the id from ws-A — must 403 or 404
resp = client.get(
f"/v1/api/workstreams/ws-B/attachments/{aid}/content",
headers=_auth("userB"),
)
# userB owns ws-B, so the ws-access check passes; the id-mismatch then
# returns 404 to avoid leaking existence.
assert resp.status_code == 404
def test_get_content_unowned_ws_user_isolation(self, app_client):
# Regression for PR #356 review: in a workstream without an
# explicit owner (user_id == ""), one user's attachment must
# not be fetchable by another user via id-guessing.
client, _ = app_client
from turnstone.core.memory import register_workstream
register_workstream("ws-shared", name="shared")
a_aid = _upload(client, "ws-shared", "userA", "secret.md", b"S", "text/markdown")
# userB can reach ws-shared (owner blank → no ownership gate)
# but must NOT be able to fetch userA's blob.
resp = client.get(
f"/v1/api/workstreams/ws-shared/attachments/{a_aid}/content",
headers=_auth("userB"),
)
assert resp.status_code == 404
# userA still gets their own
resp = client.get(
f"/v1/api/workstreams/ws-shared/attachments/{a_aid}/content",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.content == b"S"
class TestGetThumbnail:
"""The /thumbnail handler + the shared _resolve_served_blob gate it reuses:
200+png for image/pdf, 415 for non-thumbnailable kinds or a failed render,
and a 404 (no existence leak) for cross-ws / cross-user id access."""
def _thumb_url(self, ws_id: str, aid: str) -> str:
return f"/v1/api/workstreams/{ws_id}/attachments/{aid}/thumbnail"
def test_image_thumbnail_200_png_with_hardening_headers(self, app_client):
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.png", PNG_1x1, "image/png")
resp = client.get(self._thumb_url("ws-A", aid), headers=_auth("userA"))
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
assert resp.content[:8] == b"\x89PNG\r\n\x1a\n"
assert resp.headers.get("x-content-type-options") == "nosniff"
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
assert "max-age=300" in resp.headers.get("cache-control", "")
def test_audio_thumbnail_415(self, app_client):
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "a.wav", WAV_12, "audio/wav")
resp = client.get(self._thumb_url("ws-A", aid), headers=_auth("userA"))
assert resp.status_code == 415
def test_text_thumbnail_415(self, app_client):
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "n.md", b"# hi\n", "text/markdown")
resp = client.get(self._thumb_url("ws-A", aid), headers=_auth("userA"))
assert resp.status_code == 415
def test_thumbnail_unavailable_returns_415(self, app_client, monkeypatch):
# kind is image (reaches make_thumbnail) but the render yields None.
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.png", PNG_1x1, "image/png")
monkeypatch.setattr("turnstone.core.thumbnails.make_thumbnail", lambda *a, **k: None)
resp = client.get(self._thumb_url("ws-A", aid), headers=_auth("userA"))
assert resp.status_code == 415
def test_thumbnail_cross_workstream_id_404(self, app_client):
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.png", PNG_1x1, "image/png")
# userB owns ws-B; the id belongs to ws-A → 404 (no existence leak).
resp = client.get(self._thumb_url("ws-B", aid), headers=_auth("userB"))
assert resp.status_code == 404
def test_thumbnail_unowned_ws_user_isolation_404(self, app_client):
client, _ = app_client
from turnstone.core.memory import register_workstream
register_workstream("ws-shared-thumb", name="shared")
aid = _upload(client, "ws-shared-thumb", "userA", "s.png", PNG_1x1, "image/png")
# Blank-owner ws is reachable by userB, but userA's blob must not be.
resp = client.get(self._thumb_url("ws-shared-thumb", aid), headers=_auth("userB"))
assert resp.status_code == 404
resp = client.get(self._thumb_url("ws-shared-thumb", aid), headers=_auth("userA"))
assert resp.status_code == 200
class TestDelete:
def test_delete_pending(self, app_client):
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.md", b"x", "text/markdown")
resp = client.delete(f"/v1/api/workstreams/ws-A/attachments/{aid}", headers=_auth("userA"))
assert resp.status_code == 200
# Gone now
resp = client.delete(f"/v1/api/workstreams/ws-A/attachments/{aid}", headers=_auth("userA"))
assert resp.status_code == 404
def test_delete_cluster_wide(self, app_client):
# Trusted-team model: any authenticated caller can delete an
# attachment on any workstream. The filed ``user_id`` stays
# for audit even after a cross-caller delete.
client, _ = app_client
aid = _upload(client, "ws-A", "userA", "t.md", b"x", "text/markdown")
resp = client.delete(f"/v1/api/workstreams/ws-A/attachments/{aid}", headers=_auth("userB"))
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# /api/send with attachment_ids
# ---------------------------------------------------------------------------
class TestSendMessageAttachments:
def _wire_ws(self, mgr, ws_id: str, user_id: str):
"""Install a mock Workstream that captures session.send kwargs."""
from turnstone.core.workstream import WorkstreamState
session = MagicMock()
session._cancel_event = threading.Event()
session.queue_message = MagicMock()
# A bare Mock's auto-created ``_nudge_queue`` (truthy, has_pending
# truthy, no-op deliver) turns the worker-exit wake backstop into an
# endless respawn loop; declare this a stub session WITHOUT a queue
# so the wake gate's stub-guard bails.
session._nudge_queue = None
captured: dict = {}
def fake_send(message, attachments=None, send_id=None, client_send_ids=()):
captured["message"] = message
captured["attachments"] = attachments
captured["send_id"] = send_id
captured["client_send_ids"] = client_send_ids
session.send = fake_send
ui = MagicMock()
ui._ws_lock = threading.Lock()
ui._ws_messages = 0
ui._ws_turn_tool_calls = 0
ws = MagicMock()
ws.id = ws_id
ws.state = WorkstreamState.IDLE
ws.ui = ui
ws.session = session
ws.worker_thread = None
ws._worker_running = False
_harden_ws_mock(ws)
mgr.get.return_value = ws
return captured, session
def _wire_admission_ws(self, mgr, ws_id: str, user_id: str):
"""Install a real session whose send stops after USER admission.
The HTTP resolver and ``ChatSession._append_user_turn`` then exercise
the production staged-buffer transfer without paying for a model call.
The real ``send`` derives the accepted turn's sender from the immutable
worker claim; mirror that boundary instead of reading the independently
owner-scoped attachment buffer identity.
"""
from turnstone.core.session import ChatSession
from turnstone.core.session_worker import current_worker_claim
from turnstone.core.workstream import WorkstreamState
ui = MagicMock()
ui._ws_lock = threading.Lock()
ui._ws_messages = 0
ui._ws_turn_tool_calls = 0
session = ChatSession(
client=MagicMock(),
model="test-model",
ui=ui,
instructions=None,
temperature=0.3,
max_tokens=1024,
tool_timeout=10,
ws_id=ws_id,
user_id=user_id,
)
def admit_only(message, attachments=None, send_id=None):
claim = current_worker_claim(session)
if claim is None:
raise RuntimeError("admission test worker is missing its principal claim")
session._append_user_turn(
message,
attachments or (),
send_id=send_id,
sender_user_id=claim.principal_id,
)
session.send = admit_only # type: ignore[assignment]
ws = MagicMock()
ws.id = ws_id
ws.state = WorkstreamState.IDLE
ws.ui = ui
ws.session = session
ws.worker_thread = None
ws._worker_running = False
_harden_ws_mock(ws)
mgr.get.return_value = ws
return ws, session
def test_send_explicit_attachment_ids_resolves_and_passes(self, app_client):
client, mgr = app_client
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
aid = _upload(client, "ws-A", "userA", "n.md", b"hi", "text/markdown")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "review", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
# Give the worker thread a moment to run fake_send
import time
for _ in range(50):
if "attachments" in captured:
break
time.sleep(0.01)
assert captured.get("message") == "review"
atts = captured["attachments"]
assert atts is not None and len(atts) == 1
assert atts[0].attachment_id == aid
assert atts[0].kind == "text"
def test_send_validates_and_threads_client_send_id(self, app_client):
client, mgr = app_client
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
invalid = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "bad token", "client_send_id": "spaces are invalid"},
headers=_auth("userA"),
)
assert invalid.status_code == 400
assert invalid.json()["error"] == ("client_send_id must match [A-Za-z0-9_-]{1,128}")
accepted = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "correlated", "client_send_id": "browser-send_1"},
headers=_auth("userA"),
)
assert accepted.status_code == 200
import time
for _ in range(50):
if captured.get("message"):
break
time.sleep(0.01)
assert captured["client_send_ids"] == ("browser-send_1",)
def test_send_auto_consumes_pending_when_ids_omitted(self, app_client):
client, mgr = app_client
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
_upload(client, "ws-A", "userA", "a.md", b"A", "text/markdown")
_upload(client, "ws-A", "userA", "b.md", b"B", "text/markdown")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "do"},
headers=_auth("userA"),
)
assert resp.status_code == 200
import time
for _ in range(50):
if "attachments" in captured:
break
time.sleep(0.01)
assert captured["attachments"] is not None
assert len(captured["attachments"]) == 2
@pytest.mark.parametrize("acting_user", ["userA", "shared-userB"])
@pytest.mark.parametrize("explicit_ids", [True, False])
def test_owner_scoped_admission_consumes_once_for_shared_sender(
self,
app_client,
acting_user,
explicit_ids,
):
"""Actor identity and staged-byte ownership are separate scopes.
Trusted-team workstreams file pending uploads under the durable
workstream owner. A different authenticated participant is still the
turn sender, but USER admission must transfer that owner-scoped upload
exactly once for both explicit-id and auto-consume requests.
"""
from turnstone.core.attachment_buffer import get_attachment_buffer
from turnstone.core.storage import get_storage
client, mgr = app_client
ws, session = self._wire_admission_ws(mgr, "ws-A", "userA")
aid = _upload(client, "ws-A", acting_user, "shared.md", b"shared", "text/markdown")
buffer = get_attachment_buffer()
assert buffer.get(aid, ws_id="ws-A", user_id="userA") is not None
if acting_user != "userA":
assert buffer.get(aid, ws_id="ws-A", user_id=acting_user) is None
body: dict[str, object] = {"message": "review together"}
if explicit_ids:
body["attachment_ids"] = [aid]
response = client.post(
"/v1/api/workstreams/ws-A/send",
json=body,
headers=_auth(acting_user),
)
assert response.status_code == 200
assert response.json()["attached_ids"] == [aid]
worker = ws.worker_thread
assert worker is not None
worker.join(timeout=5)
assert not worker.is_alive()
assert buffer.get(aid, ws_id="ws-A", user_id="userA") is None
storage = get_storage()
rows = storage.load_messages("ws-A", repair=False)
user_rows = [row for row in rows if row.get("role") == "user"]
assert len(user_rows) == 1
assert user_rows[0]["_sender"] == acting_user
stored_attachment = storage.get_attachment(aid)
assert stored_attachment is not None
assert stored_attachment["refcount"] == 1
assert session.has_unresolved_conversation_persistence() is False
def test_send_empty_list_disables_autoconsume(self, app_client):
client, mgr = app_client
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
_upload(client, "ws-A", "userA", "a.md", b"A", "text/markdown")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "plain", "attachment_ids": []},
headers=_auth("userA"),
)
assert resp.status_code == 200
import time
for _ in range(50):
if "attachments" in captured:
break
time.sleep(0.01)
assert captured["attachments"] is None # send got None, no attachments
def test_send_preserves_explicit_attachment_id_order(self, app_client):
client, mgr = app_client
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
a = _upload(client, "ws-A", "userA", "a.md", b"A", "text/markdown")
b = _upload(client, "ws-A", "userA", "b.md", b"B", "text/markdown")
c = _upload(client, "ws-A", "userA", "c.md", b"C", "text/markdown")
# Request order: c, a, b — must be preserved through resolution
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={
"message": "ordered",
"attachment_ids": [c, a, b],
},
headers=_auth("userA"),
)
assert resp.status_code == 200
import time
for _ in range(50):
if "attachments" in captured:
break
time.sleep(0.01)
atts = captured["attachments"]
assert [x.attachment_id for x in atts] == [c, a, b]
def test_send_unknown_ids_resolve_to_nothing(self, app_client):
# The old oversized-IN-clause / cap rejection is gone (no DB
# reservation, no per-user cap). Unknown ids simply don't resolve
# from the buffer — the send proceeds with no attachments rather
# than 400-ing.
client, mgr = app_client
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
many = [f"id-{i}" for i in range(50)]
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "x", "attachment_ids": many},
headers=_auth("userA"),
)
assert resp.status_code == 200
import time
for _ in range(50):
if "attachments" in captured:
break
time.sleep(0.01)
assert captured["attachments"] is None
def test_send_forged_id_from_other_user_ignored(self, app_client):
client, mgr = app_client
# userB uploads to ws-B
_upload(client, "ws-B", "userB", "secret.md", b"secret", "text/markdown")
# userA tries to include userB's attachment id in their send on ws-A
resp = client.get("/v1/api/workstreams/ws-B/attachments", headers=_auth("userB"))
atts = resp.json()["attachments"]
assert len(atts) == 1
stolen_id = atts[0]["attachment_id"]
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={
"message": "sneaky",
"attachment_ids": [stolen_id],
},
headers=_auth("userA"),
)
assert resp.status_code == 200
import time
for _ in range(50):
if "attachments" in captured:
break
time.sleep(0.01)
# Forged id is scope-rejected — no attachments reach session.send
assert captured["attachments"] is None
class TestQueuedSendWithAttachments:
"""When the worker is busy, send queues the message + attachment_ids
so the multimodal turn isn't silently reduced to text on dequeue."""
def _wire_busy_ws(self, mgr, ws_id: str):
"""Mock ws whose worker_thread is always 'alive' (forces queue path).
Captures args passed to queue_message so the test can assert on
ordered attachment_ids.
"""
from turnstone.core.workstream import WorkstreamState
captured: dict = {}
def fake_queue_message(
text, attachment_ids=None, queue_msg_id=None, interjector_user_id=""
):
captured["text"] = text
captured["attachment_ids"] = list(attachment_ids or ())
captured["queue_msg_id"] = queue_msg_id
captured["interjector_user_id"] = interjector_user_id
# Return the supplied id so server-side tracking is coherent
return text, "notice", queue_msg_id or "q-msg-1"
session = MagicMock()
session._cancel_event = threading.Event()
session.queue_message = fake_queue_message
# Stub session without a NudgeQueue — see _wire_ws for why a bare
# Mock queue would feed the exit backstop an endless wake loop.
session._nudge_queue = None
ui = MagicMock()
ui._ws_lock = threading.Lock()
ui._ws_messages = 0
ui._ws_turn_tool_calls = 0
# _worker_running=True forces session_worker.send onto the queue path
worker = MagicMock()
worker.is_alive = MagicMock(return_value=True)
ws = MagicMock()
ws.id = ws_id
ws.state = WorkstreamState.RUNNING
ws.ui = ui
ws.session = session
ws.worker_thread = worker
ws._worker_running = True
_harden_ws_mock(ws)
mgr.get.return_value = ws
return captured
def test_busy_queue_carries_ordered_attachment_ids(self, app_client):
client, mgr = app_client
captured = self._wire_busy_ws(mgr, "ws-A")
a = _upload(client, "ws-A", "userA", "a.md", b"A", "text/markdown")
b = _upload(client, "ws-A", "userA", "b.md", b"B", "text/markdown")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={
"message": "ping",
"attachment_ids": [b, a], # intentionally reversed
},
headers=_auth("userA"),
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "queued"
assert captured["text"] == "ping"
# Ordered ids must reach queue_message so dequeue flushes them
# as a properly-ordered multipart turn.
assert captured["attachment_ids"] == [b, a]
class TestBusyWorkerAttachments:
"""An attachment-bearing send to a busy worker can't ride the text-only
queue seam — it returns ``attachments_busy`` and the staged bytes stay in
the buffer (a peek, not a drain) so the client can retry once idle."""
def _wire_busy_ws(self, mgr, ws_id: str):
"""Mock ws whose worker is always alive (forces the queue path)."""
from turnstone.core.session import ChatSession
from turnstone.core.workstream import WorkstreamState
session = ChatSession(
client=MagicMock(),
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.3,
max_tokens=1024,
tool_timeout=10,
user_id="userA",
)
session._ws_id = ws_id
ui = MagicMock()
ui._ws_lock = threading.Lock()
ui._ws_messages = 0
ui._ws_turn_tool_calls = 0
worker = MagicMock()
worker.is_alive = MagicMock(return_value=True)
ws = MagicMock()
ws.id = ws_id
ws.state = WorkstreamState.RUNNING
ws.ui = ui
ws.session = session
ws.worker_thread = worker
# No explicit _worker_running: the truthy auto-Mock forces the
# queue path (deliberate — see _harden_ws_mock's exclusion note).
_harden_ws_mock(ws)
mgr.get.return_value = ws
return ws, session
def test_send_with_attachments_to_busy_worker_returns_attachments_busy(self, app_client):
client, mgr = app_client
aid = _upload(client, "ws-A", "userA", "x.md", b"X", "text/markdown")
self._wire_busy_ws(mgr, "ws-A")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "with file", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "attachments_busy"
assert body["attached_ids"] == []
assert body["dropped_attachment_ids"] == [aid]
# The staged upload was peeked (not drained), so it's still pending
# and visible for a retry once the worker idles.
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
ids = [a["attachment_id"] for a in resp.json()["attachments"]]
assert aid in ids
class TestServiceScopedActorFlow:
"""Service-scoped tokens bypass ownership checks and file attachments
under the workstream owner; send() must consume them using the same
owner-resolution helper (not the raw caller id)."""
def test_service_upload_then_send_consumes(self, app_client):
client, mgr = app_client
# Service token: has the 'service' scope
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
service_token = create_jwt(
user_id="svc-bot",
scopes=frozenset({"read", "write", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
svc_headers = {"Authorization": f"Bearer {service_token}"}
# Upload to ws-A (owned by userA) as service — the staged upload is
# filed under the owner "userA" (the resolver's uid), not "svc-bot",
# so the later owner-resolved send can find it in the buffer.
resp = client.post(
"/v1/api/workstreams/ws-A/attachments",
files={"file": ("svc.md", b"svc", "text/markdown")},
headers=svc_headers,
)
assert resp.status_code == 200
aid = resp.json()["attachment_id"]
from turnstone.core.attachment_buffer import get_attachment_buffer
# Staged under the owner uid (userA), not the service caller (svc-bot).
assert get_attachment_buffer().get(aid, ws_id="ws-A", user_id="userA") is not None
assert get_attachment_buffer().get(aid, ws_id="ws-A", user_id="svc-bot") is None
# Now drive /api/send as the service token — the resolver uses
# the ws owner (userA) to look up attachments, so the upload is
# found and passed through.
captured, _ = TestSendMessageAttachments._wire_ws(
TestSendMessageAttachments(),
mgr,
"ws-A",
"userA",
)
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"message": "svc send", "attachment_ids": [aid]},
headers=svc_headers,
)
assert resp.status_code == 200
import time
for _ in range(50):
if "attachments" in captured:
break
time.sleep(0.01)
atts = captured["attachments"]
assert atts is not None and len(atts) == 1
assert atts[0].attachment_id == aid
# ---------------------------------------------------------------------------
# Voice I/O (STT / TTS) endpoints
# ---------------------------------------------------------------------------
class _VoiceConfigStore:
def __init__(self, **values: str) -> None:
self._values = dict(values)
def get(self, key: str, default: str = "") -> str:
return self._values.get(key, default)
@pytest.fixture
def voice_app_client(tmp_path):
"""App wired with an audio-capable registry alias + a mocked OpenAI client.
The mock is injected into ``registry._clients`` so the real endpoint →
resolve_role_alias → transcribe/synthesize path runs end-to-end with only
the SDK network call stubbed.
"""
import sqlalchemy as sa
import turnstone.server as srv_mod
from turnstone.core.memory import register_workstream
from turnstone.core.metrics import MetricsCollector
from turnstone.core.model_registry import ModelConfig, ModelRegistry
from turnstone.core.storage import init_storage, reset_storage
from turnstone.core.storage._registry import get_storage
from turnstone.core.storage._schema import workstreams as ws_tbl
db_path = tmp_path / "voice.db"
reset_storage()
init_storage("sqlite", path=str(db_path), run_migrations=False)
srv_mod._metrics = MetricsCollector()
srv_mod._metrics.model = "test-model"
register_workstream("ws-A", name="A")
with get_storage()._conn() as conn:
conn.execute(sa.update(ws_tbl).where(ws_tbl.c.ws_id == "ws-A").values(user_id="userA"))
conn.commit()
registry = ModelRegistry(
models={
"voice": ModelConfig(
"voice",
"http://localhost:9/v1",
"none",
"gpt-4o-mini-tts",
capabilities={
"supports_transcription": True,
"supports_speech_synthesis": True,
},
),
},
default="voice",
)
mock_client = MagicMock()
transcription_response = MagicMock()
transcription_response.parse.return_value = MagicMock(text="hello from speech")
transcription_manager = MagicMock()
transcription_manager.__enter__.return_value = transcription_response
transcription_manager.__exit__.return_value = False
mock_client.audio.transcriptions.with_streaming_response.create.return_value = (
transcription_manager
)
speech_response = MagicMock()
speech_response.read.return_value = b"RIFF\x00\x00fakeaudio"
speech_manager = MagicMock()
speech_manager.__enter__.return_value = speech_response
speech_manager.__exit__.return_value = False
mock_client.audio.speech.with_streaming_response.create.return_value = speech_manager
mock_client._voice_transcription_response = transcription_response
registry._clients["voice"] = mock_client # bypass real SDK client construction
config_store = _VoiceConfigStore(
**{
"audio.stt_model_alias": "voice",
"audio.tts_model_alias": "voice",
"audio.tts_voice": "alloy",
}
)
mock_mgr = MagicMock()
mock_mgr.get.return_value = None
mock_mgr.list_all.return_value = []
mock_mgr.max_active = 10
app = srv_mod.create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
jwt_secret=_TEST_JWT_SECRET,
registry=registry,
config_store=config_store,
)
client = TestClient(app, raise_server_exceptions=False)
try:
yield client, mock_client
finally:
client.close()
reset_storage()
class TestSpeechToText:
def test_unconfigured_returns_503(self, app_client):
client, _ = app_client
resp = client.post(
"/v1/api/workstreams/ws-A/speech-to-text",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth("userA"),
)
assert resp.status_code == 503
assert "not configured" in resp.json()["error"]
def test_happy_path_returns_transcript(self, voice_app_client):
client, mock_client = voice_app_client
resp = client.post(
"/v1/api/workstreams/ws-A/speech-to-text",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth("userA"),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["transcript"] == "hello from speech"
assert body["model_alias"] == "voice"
assert mock_client.audio.transcriptions.with_streaming_response.create.called
def test_empty_upload_returns_400(self, voice_app_client):
client, _ = voice_app_client
resp = client.post(
"/v1/api/workstreams/ws-A/speech-to-text",
files={"audio": ("speech.webm", b"", "audio/webm")},
headers=_auth("userA"),
)
assert resp.status_code == 400
def test_silence_returns_422(self, voice_app_client):
# A successful transcription with no speech is not a backend failure.
client, mock_client = voice_app_client
mock_client._voice_transcription_response.parse.return_value = MagicMock(text=" ")
resp = client.post(
"/v1/api/workstreams/ws-A/speech-to-text",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth("userA"),
)
assert resp.status_code == 422
assert "No speech detected" in resp.json()["error"]
def test_backend_failure_returns_masked_502(self, voice_app_client):
# Backend SDK error detail must not leak into the client-facing body.
client, mock_client = voice_app_client
create = mock_client.audio.transcriptions.with_streaming_response.create
create.side_effect = RuntimeError("Error code: 401 - internal-host:9 invalid_api_key")
resp = client.post(
"/v1/api/workstreams/ws-A/speech-to-text",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth("userA"),
)
assert resp.status_code == 502
body = resp.json()
assert body["error"] == "Speech transcription backend failed"
assert "internal-host" not in body["error"]
def test_unknown_workstream_404(self, voice_app_client):
# Trusted-team semantics: ownership isn't row-enforced, but a
# nonexistent workstream is masked as 404 (no enumeration).
client, _ = voice_app_client
resp = client.post(
"/v1/api/workstreams/ws-DOES-NOT-EXIST/speech-to-text",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth("userA"),
)
assert resp.status_code == 404
class TestSpeechToTextStream:
def test_dedicated_endpoint_streams_transcript(self, voice_app_client):
client, mock_client = voice_app_client
resp = client.post(
"/v1/api/workstreams/ws-A/speech-to-text/stream",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth("userA"),
)
assert resp.status_code == 200, resp.text
assert resp.text == "hello from speech"
assert mock_client.audio.transcriptions.with_streaming_response.create.called
class TestTextToSpeech:
def test_unconfigured_returns_503(self, app_client):
client, _ = app_client
resp = client.post("/v1/api/tts", json={"text": "hello"}, headers=_auth("userA"))
assert resp.status_code == 503
def test_happy_path_returns_audio(self, voice_app_client):
client, mock_client = voice_app_client
resp = client.post("/v1/api/tts", json={"text": "hello"}, headers=_auth("userA"))
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("audio/")
assert resp.content == b"RIFF\x00\x00fakeaudio"
assert resp.headers.get("x-model-alias") == "voice"
# audio.tts_voice setting supplies the voice when the body omits one.
create = mock_client.audio.speech.with_streaming_response.create
assert create.call_args.kwargs["voice"] == "alloy"
def test_empty_text_returns_400(self, voice_app_client):
client, _ = voice_app_client
resp = client.post("/v1/api/tts", json={"text": " "}, headers=_auth("userA"))
assert resp.status_code == 400
def test_too_long_text_returns_400(self, voice_app_client):
client, _ = voice_app_client
resp = client.post("/v1/api/tts", json={"text": "x" * 9000}, headers=_auth("userA"))
assert resp.status_code == 400
def test_backend_failure_returns_masked_502(self, voice_app_client):
client, mock_client = voice_app_client
create = mock_client.audio.speech.with_streaming_response.create
create.side_effect = RuntimeError("Error code: 500 - internal-host:9 boom")
resp = client.post("/v1/api/tts", json={"text": "hello"}, headers=_auth("userA"))
assert resp.status_code == 502
body = resp.json()
assert body["error"] == "Speech synthesis backend failed"
assert "internal-host" not in body["error"]
def _voice_request(client, route: str, *, user_id: str):
if route == "stt":
return client.post(
"/v1/api/workstreams/ws-A/speech-to-text",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth(user_id),
)
if route == "stt-stream":
return client.post(
"/v1/api/workstreams/ws-A/speech-to-text/stream",
files={"audio": ("speech.webm", b"RIFFfake", "audio/webm")},
headers=_auth(user_id),
)
return client.post("/v1/api/tts", json={"text": "hello"}, headers=_auth(user_id))
class TestVoiceBackendAuth:
@pytest.mark.parametrize("route", ["stt", "stt-stream", "tts"])
def test_request_principal_is_used_instead_of_workstream_owner(
self,
voice_app_client,
route,
):
from dataclasses import replace
client, mock_client = voice_app_client
registry = client.app.state.registry
cfg = registry._models["voice"]
registry._models["voice"] = replace(
cfg,
api_key="",
auth_mode="entra_obo",
obo_audience="api://voice",
)
mint_client = MagicMock()
mint_client.mint_model_obo_token_sync.return_value = "minted-token"
client.app.state.mcp_client = mint_client
mock_client.with_options.return_value = mock_client
response = _voice_request(client, route, user_id="userB")
assert response.status_code == 200, response.text
mint_client.mint_model_obo_token_sync.assert_called_once_with(
user_id="userB",
alias="voice",
audience="api://voice",
scopes="",
grant_leg="entra",
)
mock_client.with_options.assert_called_once_with(api_key="minted-token")
@pytest.mark.parametrize("route", ["stt", "stt-stream", "tts"])
def test_auth_failure_is_masked_and_never_dispatches(
self,
voice_app_client,
route,
):
from dataclasses import replace
client, mock_client = voice_app_client
registry = client.app.state.registry
cfg = registry._models["voice"]
registry._models["voice"] = replace(
cfg,
api_key="",
auth_mode="entra_obo",
obo_audience="api://voice",
)
mint_client = MagicMock()
mint_client.mint_model_obo_token_sync.return_value = None
client.app.state.mcp_client = mint_client
response = _voice_request(client, route, user_id="userB")
assert response.status_code == 503
assert response.json() == {"error": "Model backend authentication unavailable"}
mock_client.audio.transcriptions.with_streaming_response.create.assert_not_called()
mock_client.audio.speech.with_streaming_response.create.assert_not_called()
class TestVoiceStreamLifecycle:
def test_response_call_cancellation_aborts_opened_handle(self, monkeypatch):
import asyncio
from starlette.responses import StreamingResponse
from turnstone.core.deadline import StreamAbortRef
from turnstone.server import _AbortOnExitStreamingResponse
handle = MagicMock()
abort_ref = StreamAbortRef()
abort_ref.append(handle)
async def cancel_before_body(self, scope, receive, send):
raise asyncio.CancelledError
monkeypatch.setattr(StreamingResponse, "__call__", cancel_before_body)
response = _AbortOnExitStreamingResponse([], abort_ref=abort_ref)
with pytest.raises(asyncio.CancelledError):
asyncio.run(response({}, MagicMock(), MagicMock()))
assert abort_ref.aborted
handle.close.assert_called()
# ---------------------------------------------------------------------------
# GET /preview — the renderable serving route (preview pane)
# ---------------------------------------------------------------------------
def _seed_committed(ws_id: str, kind: str, mime: str, body: bytes, filename: str) -> str:
"""Commit a blob the way the open_preview fold does: content-addressed
save + a tool row whose ref-list names it (the serving ownership gate)."""
import hashlib
from turnstone.core.memory import save_attachment, save_message, set_message_attachments
aid = hashlib.sha256(b"preview:" + body).hexdigest()
save_attachment(aid, filename, mime, len(body), kind, body, "tool")
row_id = save_message(ws_id, "tool", "Preview shown", "open_preview", tool_call_id="c1")
assert row_id is not None
set_message_attachments(ws_id, row_id, [aid])
return aid
class TestGetPreview:
def test_html_default_serves_locked_down_csp(self, app_client):
client, _ = app_client
body = b'<html><head><base href="https://acme.com/"></head><body>x</body></html>'
aid = _seed_committed("ws-A", "preview", "text/html; charset=utf-8", body, "preview-web")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert resp.content == body
# Default (no ?assets): renderable but off the network — sandboxed,
# inline styling + data-URI images only, so previewing discloses
# nothing to the origin site.
assert resp.headers.get("content-security-policy") == (
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:"
)
assert resp.headers.get("x-content-type-options") == "nosniff"
assert resp.headers.get("content-disposition", "").startswith("inline;")
assert resp.headers.get("cache-control") == "private, no-store"
def test_html_assets_flag_serves_bare_sandbox(self, app_client):
# ?assets=1 is the per-pane opt-in: drop back to the bare sandbox so
# the page's own images / CSS load.
client, _ = app_client
body = b"<html><head></head><body>x</body></html>"
aid = _seed_committed("ws-A", "preview", "text/html; charset=utf-8", body, "preview-web")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview?assets=1",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers.get("content-security-policy") == "sandbox"
def test_pdf_served_without_csp(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "preview", "application/pdf", b"%PDF-1.4 x", "d.pdf")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/pdf")
# Chromium's viewer refuses sandboxed contexts — the route omits CSP.
assert "content-security-policy" not in resp.headers
def test_image_keeps_full_csp(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "preview", "image/png", PNG_1x1, "chart.png")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
def test_non_renderable_mime_415(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "audio", "audio/wav", WAV_12, "a.wav")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 415
def test_uploaded_attachment_also_previews(self, app_client):
# An UPLOADED image (committed via the normal user lane) renders
# through /preview too — the pane serves attachment: targets.
client, _ = app_client
aid = _seed_committed("ws-A", "image", "image/png", PNG_1x1, "up.png")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview",
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
def test_unreferenced_id_404(self, app_client):
client, _ = app_client
aid = _seed_committed("ws-A", "preview", "text/html", b"<p>x</p>", "p")
resp = client.get(
f"/v1/api/workstreams/ws-B/attachments/{aid}/preview",
headers=_auth("userB"),
)
assert resp.status_code == 404
def test_probe_returns_204_with_hardening_headers(self, app_client):
# The pane preflights src-loaded kinds with ?probe=1 instead of HEAD:
# the console reverse proxy forwards a HEAD as a full GET, so a real
# HEAD would drag the whole blob across the hop just to discard it. The
# probe runs the ownership + renderable-type gates and returns the real
# response's hardening headers with an empty body.
client, _ = app_client
body = b"<html><head></head><body>x</body></html>"
aid = _seed_committed("ws-A", "preview", "text/html; charset=utf-8", body, "preview-web")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview?probe=1",
headers=_auth("userA"),
)
assert resp.status_code == 204
assert resp.content == b""
# Same hardening headers the real GET would carry (the probe answers
# "will the load paint?"): the html CSP is present.
assert resp.headers.get("content-security-policy") == (
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:"
)
assert resp.headers.get("x-content-type-options") == "nosniff"
def test_probe_composes_with_assets_flag(self, app_client):
# ?probe=1&assets=1 → 204 whose headers reflect the assets opt-in.
client, _ = app_client
body = b"<html><head></head><body>x</body></html>"
aid = _seed_committed("ws-A", "preview", "text/html; charset=utf-8", body, "preview-web")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview?probe=1&assets=1",
headers=_auth("userA"),
)
assert resp.status_code == 204
assert resp.headers.get("content-security-policy") == "sandbox"
def test_probe_non_renderable_mime_still_415(self, app_client):
# A probe must answer "will the real load succeed?" — a non-renderable
# blob 415s exactly as the real GET would, before any 204.
client, _ = app_client
aid = _seed_committed("ws-A", "audio", "audio/wav", WAV_12, "a.wav")
resp = client.get(
f"/v1/api/workstreams/ws-A/attachments/{aid}/preview?probe=1",
headers=_auth("userA"),
)
assert resp.status_code == 415