"""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"", "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'x' 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"x" 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"

x

", "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"x" 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"x" 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