Files
turnstone/tests/test_server_attachments_on_create.py
T
Patrick Buckley d068366a61 rbac: builtin-role override editor + tighten under-enforced perm gates (#585)
* feat(rbac): editable builtin role permissions via overlay layer

Adds a ``role_permission_overrides`` table that stores per-(role_id,
permission) grant/revoke deltas, applied on top of the immutable
``roles.permissions`` baseline at permission-load time. Builtin roles
(``builtin-admin/operator/viewer``) become customizable through the
admin Roles UI without losing the "reset to default" guarantee — every
override is auditable and reversible.

Motivating case: ``model.skills.write`` is deliberately default-ungranted
on every role so operators must consciously opt in before a coordinator
session can mutate the skill catalog. Until now there was no UX path to
do that opt-in — the only options were dropping into SQL or running a
fresh migration. The overrides editor closes that gap.

Backend
- Migration 057 + storage methods on both sqlite + postgresql backends
- ``get_user_permissions`` merges baseline ∪ grants − revokes for builtin
  rows; custom rows pass through unchanged
- ``GET /v1/api/admin/roles/{id}/effective`` for inspect
- ``PUT /v1/api/admin/roles/{id}/overrides`` for write — admin.roles gated,
  audited, validates against ``_VALID_PERMISSIONS``, refuses non-builtin
  targets, strips no-op grants/revokes before persisting
- Lockout guard: cannot revoke ``admin.roles`` if doing so would leave
  zero users with the permission (returns 409)
- ``coordinator.trust.send`` added to ``_VALID_PERMISSIONS`` — was
  seeded into builtin-admin by migration 042 but never registered with
  the validator, so the very first round-trip through the editor 400'd
  on it. Drift-detection test guards future migrations from recreating
  the same gap

Frontend
- Roles tab redesign: chevron + permission-count chip replace the
  "..." truncation; expand-on-click drawer groups perms by namespace
  with baseline / grant (green +) / revoke (red −) chip variants
- Edit modal opens for builtin rows ("Customize Built-in Role" title);
  toggles show baseline-default vs override state; submit diffs against
  the rendered toggle universe (not raw baseline) so future taxonomy
  drift can't silently strip unknown perms
- "Modified +N/-N" pill on rows with active overrides; "Reset to default"
  drawer action clears the override set
- ``_PERMISSION_SECTIONS`` brought up to date with all currently-seeded
  perms (admin.coordinator, admin.cluster.inspect, admin.models,
  admin.nodes, admin.prompt_policies, conversation.modify,
  coordinator.trust.send were missing)

Tests
- 7 storage tests covering set/list/clear/effective + overlay merge into
  ``get_user_permissions`` for both builtin and custom roles
- 11 endpoint tests covering effective/overrides happy paths, validation,
  lockout guard, builtin-only restriction, no-op normalization, list
  enrichment

* feat(rbac): enforce workstreams.{create,close} + tools.approve gates

These three permissions were declared in ``_VALID_PERMISSIONS``, seeded
into ``builtin-operator``'s baseline by migration 008/017, surfaced in
the admin Roles UI as toggles, and documented in ``bootstrap.py`` as
the operator role's capabilities — and never enforced anywhere. The
audit that ran out of the overlay PR found zero ``require_permission``
sites for any of them; any authenticated user could create workstreams,
close any workstream, or approve any pending tool regardless of role.

Behaviour change for callers without the perms:

- ``POST /v1/api/workstreams/new`` (node + console proxy variants)
  now 403 without ``workstreams.create``
- ``POST /v1/api/workstreams/{ws_id}/close`` (and ``/route/`` proxy)
  now 403 without ``workstreams.close``
- ``POST /v1/api/workstreams/{ws_id}/approve`` (and ``/route/`` proxy)
  now 403 without ``tools.approve``

The OR-fallback to ``admin.coordinator`` keeps coord sessions spawning
interactive children unblocked without needing operator-style perms.
Service-scoped inter-cluster calls bypass via the existing
``allow_service_bypass`` path on the new ``require_any_permission``
helper. Builtin admin and operator both already carry these perms;
viewer correctly loses workstream create/close/approve (it already
couldn't do those in spirit).

Implementation
- ``require_any_permission`` (core/auth.py) — OR-semantics variant of
  ``require_permission`` with per-conditional comments documenting the
  security policy at the choke point. 403 body names every accepted
  perm so operators get an actionable remediation
- ``make_{create,close,approve}_handler`` (core/session_routes.py)
  accept ``fallback_permissions: tuple[str, ...]`` — checked only when
  ``cfg.permission_gate is None`` (interactive case). Coord's
  ``permission_gate=_require_admin_coordinator`` continues to take
  precedence on the coord-config side
- Console-side ``create_workstream`` and ``route_create`` inline the
  same OR check before proxying — fail fast on a forbidden request
  without burning a cluster round-trip
- ``route_proxy`` adds a verb-scoped gate on ``approve`` and ``close``
  only; ``send``/``cancel``/``dequeue``/``command``/``plan`` remain
  authenticated-only (pre-existing, out of scope for this audit)

Tests
- New ``TestPermissionGatesOnLifecycle`` (4 tests) in test_server_authz
  pinning 403-without-perm + non-403-with-perm at the node lift sites
- New ``TestRouteProxyPermissionGates`` (5 tests) in
  test_console_routing_proxy covering 403 paths, OR fallback via
  ``admin.coordinator``, and that ``send`` remains ungated
- ``_make_jwt`` helpers in test_server_authz, test_close_reason_
  persistence, test_server_attachments_on_create updated to embed
  operator-shaped perms by default so existing tests continue to
  exercise the post-gate logic rather than 403'ing on the new check

Docs
- ``bootstrap.py`` operator role line corrected to list every perm
  it actually carries (was missing ``tools.approve`` and
  ``conversation.modify``)

* fix(rbac): close lockout + escalation gaps in role-overrides editor

Three issues surfaced by /review of the overlay layer and gate uplift —
all in the RBAC/auth surface, treated as zero-days.

**F-1: lockout guard misses the grant-removal path.** PUT-replace
semantics on ``set_role_overrides`` mean an existing grant of
``admin.roles`` (added via override to e.g. builtin-operator) is
silently dropped when the new payload omits it.  The previous guard
short-circuited on ``"admin.roles" not in revokes`` and never noticed.
Concrete cluster-bricking scenario: grant admin.roles to operator via
override, unassign builtin-admin, click "Reset to default" on operator
→ all users lose admin.roles, recoverable only via SQL.

The rewritten guard simulates the post-PUT effective set on the target
role directly: if ``(baseline | new_grants) - new_revokes`` lacks
admin.roles AND nobody holds it via another role, refuse the change.
The "via another role" question is answered by one bulk query rather
than the prior O(users × roles) round-trip loop.

**F-3: lockout check blocked the event loop on moderate deployments.**
The prior check called ``storage.list_user_roles`` per user and
``storage.effective_role_permissions`` per (user, role) pair —
synchronous SQL inside an async handler.  200 users × 5 roles = 1000
connection cycles long enough to trip reverse-proxy timeouts on a
permission revoke.

Replaced with ``storage.users_with_permission(perm, *,
exclude_role_id)`` — one join over ``user_roles ⋈ roles`` plus one IN
fetch on overrides for the builtin role ids in the result, folded
in-process.  Two queries total, independent of cluster size.  The whole
check now runs under ``asyncio.to_thread`` so even the bulk read
doesn't stall the loop.

**F-2 reframed: admin_assign_role's subset check ignored the overlay.**
The check at lines 6321-6328 reads ``target_role.get("permissions",
"")`` (baseline column) when computing the perms it requires the
caller to hold.  After this branch, an admin.roles holder can grant
e.g. ``model.skills.write`` to builtin-operator via override; an
admin.users holder (who happens to NOT hold that perm) could then
assign operator to a new user, silently escalating the assignee.  The
existing two-person-rule by perm split (admin.roles for catalog edits,
admin.users for assignments) only holds if the assignment-time check
considers the overlay.  Switched ``target_perms`` to
``storage.effective_role_permissions(role_id)["effective"]``.

Note: this PR retains the existing model where admin.roles is the
catalog-edit superuser (admin_create_role, admin_update_role, and now
admin_role_overrides all skip the caller-holds-grants check).  The
two-person rule against escalation lives at the assignment gate, which
this fix reinforces.

**F-7: delete_role left orphaned override rows.** No FK on
``role_permission_overrides.role_id`` (migration 057 omitted FKs to
match the rest of the governance schema).  Added explicit cleanup in
both sqlite + postgresql ``delete_role`` implementations so a
re-seeded role_id (deterministic for builtins on schema reseed) can't
silently inherit stale overrides from the prior occupant.

Tests
- storage: ``test_users_with_permission_bulk`` exercises the new bulk
  helper including ``exclude_role_id`` and overlay folding
- storage: ``test_delete_role_cleans_up_overrides`` pins the F-7 fix
- endpoint: ``test_overrides_lockout_guard_blocks_grant_removal`` is
  the F-1 reproduction — operator-overlay grants admin.roles, builtin-
  admin has it removed, attempting to reset operator's overrides 409s
- endpoint: ``test_assign_role_blocks_escalation_via_overlay_grant``
  pins the F-2 reframed fix — overlay-poisoned operator can't be
  assigned by a caller missing the overlay perms

* refactor(rbac): cleanup batch from /review (#584)

Five non-security findings folded into one commit so the security
batch stays focused.  All consistent with the existing intent of
``feat/builtin-role-overrides``.

**F-4: presence check on ``_effectivePerms``.** ``governance.js`` was
guarding on ``Array.isArray(role.effective) && role.effective.length > 0``,
falling through to splitting ``role.permissions`` (the baseline) when
the array was empty.  For a builtin role whose overrides legitimately
revoke every baseline perm, that path silently rendered the baseline
chips with no override indicators — the inspector lied about what the
role can do.  ``_enrich_role`` always sets ``effective: []``, so
presence is the right sentinel.

**F-5: JS-side drift detector.**  Commit 1 added a Python-side test
asserting ``_VALID_PERMISSIONS`` covers every baseline perm; the
mirror invariant on the frontend went uncaught.  A new perm added to
``_VALID_PERMISSIONS`` without a matching entry in
``_PERMISSION_SECTIONS`` becomes silently un-customizable through the
admin UI (the only documented grant/revoke path).  Test parses the
JS const out via regex and asserts set-equality both directions —
detects "missing in UI" and "extra in UI" so the toggle catalog and
validator can't fork.

**F-6: bulk enrich for ``admin_list_roles``.**  Was ``1 +
2*builtin_count + 1*custom_count`` SELECTs per admin-tab open;
collapsed to one ``IN``-filtered query via new
``storage.effective_role_permissions_bulk(role_ids)``.  Implemented
on both sqlite + postgresql backends following the existing
``effective_role_permissions`` shape.

**F-8: rename ``fallback_permissions`` → ``accepted_permissions``.**
The lift body uses ``if cfg.permission_gate / elif accepted_permissions``
— mutually exclusive — so when ``permission_gate`` is None this IS
the primary gate, not a fallback to anything.  The "fallback" name
suggested a tier-2-after-tier-1 semantic that didn't exist.  Renamed
across ``make_{approve,close,create}_handler`` factories, the three
call sites in ``turnstone/server.py``, and the docstrings.

**F-9: positive lift-level tests for ``admin.coordinator``-only.**
``TestPermissionGatesOnLifecycle`` previously had a single positive
test for ``workstreams.create`` alone, plus negative-403 tests for
each verb without perms.  The OR-fallback to ``admin.coordinator``
(which keeps coord sessions spawning interactive children unblocked)
had no positive coverage at the lift code path — only at the proxy,
which exercises a different verb-dict gate.  Added three tests
(create / close / approve) that pass ``admin.coordinator`` alone and
assert non-403, so a future tightening of the accepted_permissions
tuple can't silently regress coord-driven child workstreams.

Out of scope: nit perf-4 (event-delegation refactor on
``_renderGovRoles``).  ``setSafeHtml`` rebuild is the existing
pattern across every admin tab; rewriting one tab's render path on
this branch would be drive-by inconsistent with the surrounding
codebase.  Filed as a separate concern if the Roles tab grows past
the scale where it bites.

* fix(rbac-ui): aria-expanded + row-click on Roles drawer (#585)

Two Copilot review findings on governance.js:

- Expand button was missing aria-expanded — screen readers couldn't
  announce drawer state.  Now reflects the row's expanded flag.
- Comment said "row + chevron both work" but only the chevron was
  wired.  Added data-expand-role to the row element too so the
  existing handler loop (querySelectorAll on the attribute) picks up
  both — clicking anywhere in the role row toggles the drawer.
  Edit/Delete handlers already stopPropagation so they aren't
  triggered by the row-level click.

* fix(migrations): rebase role_permission_overrides to 058

PR #560 mitigation #1 landed 057_output_assessments_llm_judge.py on
main in parallel; my migration claimed the same number, forking
alembic's head and breaking postgres.  Renumbered to 058 and
re-pointed down_revision at 057 so the chain stays linear.

No behaviour change — same DDL.  Full sweep clean (6730 passed).

* fix(migrations): update 058 revision strings to match filename

Previous commit (ea86aefc) renamed 057_role_permission_overrides.py to
058_* but the in-file revision = "057" / down_revision = "056"
strings stayed — leftover from when the file shipped as 057.  Tests
pass because alembic walks the chain by revision string, and the
strings now correctly read revision = "058" / down_revision = "057"
to make the chain linear with main's 057_output_assessments_llm_judge.

Caught locally before re-running CI; my prior `git mv` + content edit
landed as a staged rename + unstaged modification on the previous
push.
2026-05-24 18:31:23 -07:00

455 lines
17 KiB
Python

"""Tests for the multipart variant of POST /v1/api/workstreams/new.
Exercises:
- The pure helpers `_validate_and_save_uploaded_files` and
`_reserve_and_resolve_attachments` (added alongside the multipart path).
- The full create endpoint via TestClient with a FakeSession factory so
the initial-message dispatch thread runs end-to-end without an LLM.
"""
from __future__ import annotations
import json
import queue
import threading
import time
import pytest
from starlette.testclient import TestClient
# Magic-byte-valid 1x1 PNG (matches the fixture in test_server_attachments_endpoints.py)
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"
)
_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,
# ``workstreams.create`` is now a real gate on POST /workstreams/new
# — see PR adding 057_role_permission_overrides. Embed the perm so
# the multipart-create flow under test stays exercising the create
# path and not the new 403.
permissions=frozenset({"workstreams.create"}),
)
def _auth(user: str) -> dict[str, str]:
return {"Authorization": f"Bearer {_make_jwt(user)}"}
# ---------------------------------------------------------------------------
# Pure helper tests
# ---------------------------------------------------------------------------
class TestValidateAndSaveUploadedFiles:
def test_saves_image_and_text(self, tmp_path):
from turnstone.core.attachments import (
validate_and_save_uploaded_files as _validate_and_save_uploaded_files,
)
from turnstone.core.memory import list_pending_attachments
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
init_storage("sqlite", path=str(tmp_path / "t.db"), run_migrations=False)
try:
files = [
("hi.png", "image/png", PNG_1x1),
("notes.md", "text/markdown", b"# Hello\n"),
]
ids, err = _validate_and_save_uploaded_files(files, "ws-X", "userA")
assert err is None
assert len(ids) == 2
pending = list_pending_attachments("ws-X", "userA")
assert len(pending) == 2
kinds = {p["kind"] for p in pending}
assert kinds == {"image", "text"}
finally:
reset_storage()
def test_rejects_oversized_image(self, tmp_path):
from turnstone.core.attachments import IMAGE_SIZE_CAP
from turnstone.core.attachments import (
validate_and_save_uploaded_files as _validate_and_save_uploaded_files,
)
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
init_storage("sqlite", path=str(tmp_path / "t.db"), run_migrations=False)
try:
# Magic-byte-valid PNG header padded past the cap.
oversized = PNG_1x1 + b"\x00" * (IMAGE_SIZE_CAP + 1)
files = [("big.png", "image/png", oversized)]
ids, err = _validate_and_save_uploaded_files(files, "ws-X", "userA")
assert err is not None
assert err.status_code == 413
assert ids == []
finally:
reset_storage()
def test_rejects_unsupported_text(self, tmp_path):
from turnstone.core.attachments import (
validate_and_save_uploaded_files as _validate_and_save_uploaded_files,
)
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
init_storage("sqlite", path=str(tmp_path / "t.db"), run_migrations=False)
try:
# No image magic, MIME isn't text/*, extension not allowlisted
files = [("evil.bin", "application/octet-stream", b"\x00\x01\x02")]
ids, err = _validate_and_save_uploaded_files(files, "ws-X", "userA")
assert err is not None
assert err.status_code == 400
assert ids == []
finally:
reset_storage()
def test_pending_cap_returns_409(self, tmp_path):
from turnstone.core.attachments import MAX_PENDING_ATTACHMENTS_PER_USER_WS
from turnstone.core.attachments import (
validate_and_save_uploaded_files as _validate_and_save_uploaded_files,
)
from turnstone.core.memory import save_attachment
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
init_storage("sqlite", path=str(tmp_path / "t.db"), run_migrations=False)
try:
# Saturate the pending cap
for i in range(MAX_PENDING_ATTACHMENTS_PER_USER_WS):
save_attachment(
f"pre-{i}", "ws-X", "userA", f"f{i}.txt", "text/plain", 1, "text", b"x"
)
files = [("notes.md", "text/markdown", b"hello")]
ids, err = _validate_and_save_uploaded_files(files, "ws-X", "userA")
assert err is not None
assert err.status_code == 409
assert ids == []
finally:
reset_storage()
class TestReserveAndResolveAttachments:
def test_reserves_and_returns_attachments(self, tmp_path):
from turnstone.core.attachments import Attachment
from turnstone.core.attachments import (
reserve_and_resolve_attachments as _reserve_and_resolve_attachments,
)
from turnstone.core.memory import save_attachment
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
init_storage("sqlite", path=str(tmp_path / "t.db"), run_migrations=False)
try:
save_attachment("a1", "ws-X", "userA", "a.txt", "text/plain", 5, "text", b"hello")
save_attachment("a2", "ws-X", "userA", "b.png", "image/png", 91, "image", PNG_1x1)
resolved, ordered, dropped = _reserve_and_resolve_attachments(
["a1", "a2"], "send-1", "ws-X", "userA"
)
assert ordered == ["a1", "a2"]
assert dropped == []
assert len(resolved) == 2
assert all(isinstance(a, Attachment) for a in resolved)
kinds = [a.kind for a in resolved]
assert kinds == ["text", "image"]
finally:
reset_storage()
def test_double_reserve_drops_second(self, tmp_path):
from turnstone.core.attachments import (
reserve_and_resolve_attachments as _reserve_and_resolve_attachments,
)
from turnstone.core.memory import save_attachment
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
init_storage("sqlite", path=str(tmp_path / "t.db"), run_migrations=False)
try:
save_attachment("a1", "ws-X", "userA", "a.txt", "text/plain", 5, "text", b"hello")
r1, ord1, _ = _reserve_and_resolve_attachments(["a1"], "send-A", "ws-X", "userA")
assert len(r1) == 1
r2, ord2, drop2 = _reserve_and_resolve_attachments(["a1"], "send-B", "ws-X", "userA")
assert r2 == []
assert ord2 == []
assert drop2 == ["a1"]
finally:
reset_storage()
# ---------------------------------------------------------------------------
# End-to-end create endpoint tests (multipart variant)
# ---------------------------------------------------------------------------
class _FakeSession:
"""Minimal stand-in: records send() invocations from the dispatch thread.
Knows its own ``ws_id`` and ``user_id`` so it can faithfully simulate the
real ChatSession's attachment-consume step against storage. Tests then
assert that pending attachments are gone after dispatch.
"""
def __init__(self, ws_id: str = "", user_id: str = ""):
self.ws_id = ws_id
self.user_id = user_id
self.model = "test-model"
self.model_alias = "test-model"
self.messages = []
self.sends: list[tuple[str, list, str | None]] = []
self._lock = threading.Lock()
self._cancel_event = threading.Event()
self.notify_targets = ""
self._notify_on_complete = "[]"
def send(self, text, attachments=None, send_id=None):
with self._lock:
self.sends.append((text, list(attachments or []), send_id))
# Simulate the real ChatSession's consume step against storage
# so callers can assert the lifecycle landed.
if attachments and send_id and self.ws_id and self.user_id:
import uuid as _uuid
from turnstone.core.memory import mark_attachments_consumed
ids = [a.attachment_id for a in attachments]
mark_attachments_consumed(
ids,
_uuid.uuid4().hex, # synthetic conversation message id
self.ws_id,
self.user_id,
reserved_for_msg_id=send_id,
)
# Methods the create handler may call but we don't care about
def set_watch_runner(self, *_a, **_kw):
pass
def queue_message(self, *_a, **_kw):
return ("", "normal", "msg-x")
def request_title_refresh(self, *_a, **_kw):
pass
def resume(self, *_a, **_kw):
return False
class _FakeUI:
def __init__(self, ws_id="", user_id=""):
self.ws_id = ws_id
self._user_id = user_id
self.auto_approve = False
self.auto_approve_tools: set[str] = set()
self.events: list[dict] = []
self._enqueued: list[dict] = []
def _enqueue(self, ev):
self._enqueued.append(ev)
def on_stream_end(self):
pass
def on_state_change(self, state):
self.events.append({"type": "state_change", "state": state})
def on_error(self, msg):
self.events.append({"type": "error", "message": msg})
@pytest.fixture
def app_client(tmp_path, monkeypatch):
"""End-to-end app with a fake session factory + SessionManager."""
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.metrics import MetricsCollector
from turnstone.core.session_manager import SessionManager
from turnstone.core.storage import get_storage, init_storage, reset_storage
from turnstone.server import WebUI, create_app
reset_storage()
init_storage("sqlite", path=str(tmp_path / "t.db"), run_migrations=False)
metrics = MetricsCollector()
metrics.model = "test-model"
monkeypatch.setattr("turnstone.server._metrics", metrics)
# Replace WebUI with our fake so the create handler's isinstance check passes.
monkeypatch.setattr("turnstone.server.WebUI", _FakeUI)
fake_sessions: list[_FakeSession] = []
def _factory(ui, _model, ws_id, **_kw):
# The user_id rides on the WebUI factory closure; pull it off the
# ui instance so the FakeSession's consume step uses the right scope.
user_id = getattr(ui, "_user_id", "")
s = _FakeSession(ws_id=ws_id, user_id=user_id)
fake_sessions.append(s)
return s
gq: queue.Queue[dict] = queue.Queue(maxsize=1000)
WebUI._global_queue = gq
adapter = InteractiveAdapter(
global_queue=gq,
ui_factory=lambda ws: _FakeUI(
ws_id=ws.id,
user_id=ws.user_id,
),
session_factory=_factory,
)
mgr = SessionManager(
adapter, storage=get_storage(), max_active=10, node_id="node-test", event_emitter=adapter
)
app = create_app(
workstreams=mgr,
global_queue=gq,
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
try:
yield client, fake_sessions, gq
finally:
client.close()
reset_storage()
class TestCreateMultipart:
def test_create_with_image_and_initial_message(self, app_client):
from turnstone.core.memory import list_pending_attachments
client, sessions, _gq = app_client
meta = {"name": "demo", "initial_message": "describe this image"}
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": json.dumps(meta)},
files=[("file", ("tiny.png", PNG_1x1, "image/png"))],
headers=_auth("userA"),
)
assert resp.status_code == 200, resp.text
data = resp.json()
ws_id = data["ws_id"]
assert ws_id
assert len(data["attachment_ids"]) == 1
# Wait briefly for the dispatch thread
deadline = time.time() + 2.0
while time.time() < deadline and not sessions:
time.sleep(0.02)
deadline = time.time() + 2.0
while time.time() < deadline and not sessions[0].sends:
time.sleep(0.02)
assert sessions
assert sessions[0].sends, "session.send was not invoked"
text, atts, send_id = sessions[0].sends[0]
assert text == "describe this image"
assert send_id # reservation token threaded through
assert len(atts) == 1
assert atts[0].kind == "image"
# Lifecycle: the FakeSession marks them consumed via storage —
# so the pending-list for this ws should be empty after dispatch.
assert list_pending_attachments(ws_id, "userA") == []
def test_create_with_attachments_no_initial_message_keeps_pending(self, app_client):
from turnstone.core.memory import list_pending_attachments
client, _, _gq = app_client
meta = {"name": "stash"}
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": json.dumps(meta)},
files=[("file", ("notes.md", b"# hello\n", "text/markdown"))],
headers=_auth("userA"),
)
assert resp.status_code == 200, resp.text
data = resp.json()
ws_id = data["ws_id"]
pending = list_pending_attachments(ws_id, "userA")
assert len(pending) == 1
assert pending[0]["filename"] == "notes.md"
def test_create_rejects_oversized_image_and_rolls_back(self, app_client):
from turnstone.core.attachments import IMAGE_SIZE_CAP
client, _, gq = app_client
oversized = PNG_1x1 + b"\x00" * (IMAGE_SIZE_CAP + 1)
meta = {"name": "fails"}
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": json.dumps(meta)},
files=[("file", ("big.png", oversized, "image/png"))],
headers=_auth("userA"),
)
assert resp.status_code == 413
# Regression: ws_created must NOT have been emitted for a request
# that's about to be rejected. Otherwise SSE consumers see a
# phantom workstream flash on dashboards.
events: list[dict] = []
while not gq.empty():
events.append(gq.get_nowait())
kinds = {e.get("type") for e in events}
assert "ws_created" not in kinds, f"phantom ws_created emitted for failed create: {events}"
def test_create_missing_meta_returns_400(self, app_client):
client, _, _gq = app_client
resp = client.post(
"/v1/api/workstreams/new",
files=[("file", ("notes.md", b"hello", "text/markdown"))],
headers=_auth("userA"),
)
assert resp.status_code == 400
def test_create_invalid_meta_json_returns_400(self, app_client):
client, _, _gq = app_client
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": "{not json}"},
files=[],
headers=_auth("userA"),
)
assert resp.status_code == 400
def test_attachments_with_resume_ws_returns_400(self, app_client):
from turnstone.core.memory import register_workstream
client, _, _gq = app_client
register_workstream("ws-resume-target", name="resume target")
meta = {"name": "fork", "resume_ws": "ws-resume-target"}
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": json.dumps(meta)},
files=[("file", ("notes.md", b"hello", "text/markdown"))],
headers=_auth("userA"),
)
assert resp.status_code == 400
class TestCreateJsonStillWorks:
"""The JSON path must remain byte-for-byte identical (back-compat)."""
def test_create_json_no_attachments(self, app_client):
client, _, _gq = app_client
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "json-only"},
headers=_auth("userA"),
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["ws_id"]
# New optional field, but always emitted (empty list when absent)
assert data["attachment_ids"] == []