mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 14:24:47 -06:00
bf55bf54ff
Mechanical updates across the test suite to swap legacy
/v1/api/{send,approve,cancel,events,workstreams/close} URLs for the
path-keyed equivalents under /v1/api/workstreams/{ws_id}/<verb>, and
to drop ws_id from request bodies (the path provides it now).
Per file:
- test_session_routes.py: deletes test_close_legacy_mounts_when_handler_provided
(the close_legacy slot is gone); test_send_mounts_post_and_delete_when_dequeue_provided
(added in PR commit 1) stays.
- test_openapi.py: expected-paths set swaps to path-keyed shape;
test_send_endpoint_has_request_body now asserts the OpenAPI for
/v1/api/workstreams/{ws_id}/send.
- test_auth.py / test_auth_identity.py: required_scope and
check_request fixtures swap to path-keyed shape; new tests cover
write/approve/read scope assignment for the path-keyed verbs +
the /node/* proxy mirror.
- test_sdk_server.py / test_sdk_console.py: mock-transport URL keys
swap; bodies drop ws_id.
- test_server_attachments_endpoints.py: ~17 send sites migrated to
/v1/api/workstreams/<ws>/send (a small Python script ran the bulk
rewrite — body ws_id stripped, URL rebuilt).
- test_server_authz.py: cross-tenant approve/close/cancel/events
tests retargeted to path-keyed URLs;
test_events_legacy_query_keyed_url_still_resolves_to_404_for_unknown_ws
renamed to test_events_path_keyed_url_resolves_to_404_for_unknown_ws
with the docstring updated to note the legacy adapter is gone.
- test_close_reason_persistence.py: 7 close sites all swap.
- test_console_routing_proxy.py: route-proxy tests swap to
/v1/api/route/workstreams/{ws_id}/<verb>; the upstream-URL
assertion now reads from .request (route_proxy uses
client.request(method, url, ...) for method passthrough); _wire_proxy
helper installs both .post and .request mocks for compatibility.
- test_route_proxy_audit.py: parametrized URLs migrated;
_make_proxy now also exposes a .request side-effect that delegates
to .post for the same compatibility surface.
- test_api_versioning.py: openapi.json path assertion swaps to the
path-keyed shape.
4557 passing under -m "not live"; ruff + mypy clean.
185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
"""Server-side tests for the close_workstream handler's close_reason
|
|
persistence — guards the seam that lets coordinator inspect surface
|
|
why a workstream was retired without scraping the audit log.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import threading
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
import turnstone.server as srv_mod
|
|
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
|
from turnstone.core.metrics import MetricsCollector
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
from turnstone.core.workstream import WorkstreamState
|
|
|
|
_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
|
|
|
|
|
def _full_hdr() -> dict[str, str]:
|
|
return {
|
|
"Authorization": (
|
|
f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', _JWT_SECRET, audience=JWT_AUD_SERVER)}"
|
|
)
|
|
}
|
|
|
|
|
|
def _make_app(storage: Any) -> TestClient:
|
|
srv_mod._metrics = MetricsCollector()
|
|
srv_mod._metrics.model = "test-model"
|
|
mock_session = MagicMock()
|
|
mock_ws = MagicMock()
|
|
mock_ws.id = "ws-target"
|
|
mock_ws.name = "test"
|
|
mock_ws.state = WorkstreamState.IDLE
|
|
mock_ws.session = mock_session
|
|
# Tenant gate (#375) checks ws.user_id == JWT subject; explicit set
|
|
# so MagicMock's auto-generated truthy attribute doesn't reject the
|
|
# request before the persistence path runs. kind / parent_ws_id
|
|
# land in the audit_detail dict alongside ``reason``.
|
|
mock_ws.user_id = "u1"
|
|
mock_ws.kind = "interactive"
|
|
mock_ws.parent_ws_id = None
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.get.return_value = mock_ws
|
|
mock_mgr.close.return_value = True
|
|
mock_mgr.list_all.return_value = [mock_ws]
|
|
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=_JWT_SECRET,
|
|
auth_storage=storage,
|
|
cors_origins=["*"],
|
|
)
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "close.db"))
|
|
|
|
|
|
def test_close_with_reason_persists_to_workstream_config(storage):
|
|
client = _make_app(storage)
|
|
resp = client.post(
|
|
"/v1/api/workstreams/ws-target/close",
|
|
json={"reason": "task complete"},
|
|
headers=_full_hdr(),
|
|
)
|
|
assert resp.status_code == 200
|
|
cfg = storage.load_workstream_config("ws-target")
|
|
assert cfg.get("close_reason") == "task complete"
|
|
|
|
|
|
def test_close_without_reason_does_not_touch_config(storage):
|
|
client = _make_app(storage)
|
|
resp = client.post(
|
|
"/v1/api/workstreams/ws-target/close",
|
|
json={},
|
|
headers=_full_hdr(),
|
|
)
|
|
assert resp.status_code == 200
|
|
cfg = storage.load_workstream_config("ws-target")
|
|
assert "close_reason" not in cfg
|
|
|
|
|
|
def test_close_reason_capped_at_512_bytes(storage):
|
|
"""A model that dumps a multi-KB blob (or a captured secret) into the
|
|
close reason must not be able to grow the workstream_config row
|
|
without bound — the handler enforces a 512-byte ceiling. Tested
|
|
with ASCII (1B/char) so the byte cap and char count coincide."""
|
|
huge = "x" * 5000
|
|
client = _make_app(storage)
|
|
resp = client.post(
|
|
"/v1/api/workstreams/ws-target/close",
|
|
json={"reason": huge},
|
|
headers=_full_hdr(),
|
|
)
|
|
assert resp.status_code == 200
|
|
cfg = storage.load_workstream_config("ws-target")
|
|
stored = cfg.get("close_reason")
|
|
assert stored is not None
|
|
assert len(stored.encode("utf-8")) <= 512
|
|
|
|
|
|
def test_close_reason_byte_cap_holds_for_multibyte_utf8(storage):
|
|
"""Repro for the char-cap-vs-byte-cap mismatch: a CJK-only payload
|
|
of 600 chars would have leaked through a code-point slice at
|
|
600*3=1800 bytes. The byte-aware cap holds it at <=512 bytes."""
|
|
huge = "\u6f22" * 600 # 3 bytes/char in UTF-8
|
|
client = _make_app(storage)
|
|
resp = client.post(
|
|
"/v1/api/workstreams/ws-target/close",
|
|
json={"reason": huge},
|
|
headers=_full_hdr(),
|
|
)
|
|
assert resp.status_code == 200
|
|
cfg = storage.load_workstream_config("ws-target")
|
|
stored = cfg.get("close_reason")
|
|
assert stored is not None
|
|
assert len(stored.encode("utf-8")) <= 512
|
|
|
|
|
|
def test_close_with_non_string_reason_drops_silently(storage):
|
|
"""A malformed body (reason=dict / list / int) should not crash the
|
|
handler — non-string reasons are coerced to empty and the close
|
|
proceeds without writing to workstream_config."""
|
|
client = _make_app(storage)
|
|
resp = client.post(
|
|
"/v1/api/workstreams/ws-target/close",
|
|
json={"reason": {"unexpected": "shape"}},
|
|
headers=_full_hdr(),
|
|
)
|
|
assert resp.status_code == 200
|
|
cfg = storage.load_workstream_config("ws-target")
|
|
assert "close_reason" not in cfg
|
|
|
|
|
|
def test_close_reason_redacts_credentials(storage):
|
|
"""A model under prompt injection that captures a secret and stuffs
|
|
it into ``reason`` must not get to plant the plaintext secret in
|
|
audit logs / workstream_config. The output guard's credential-
|
|
redaction pass runs at the close handler boundary."""
|
|
client = _make_app(storage)
|
|
secret = "AKIAIOSFODNN7EXAMPLE" # AWS access key — output guard catches.
|
|
resp = client.post(
|
|
"/v1/api/workstreams/ws-target/close",
|
|
json={"reason": f"task done; key={secret}"},
|
|
headers=_full_hdr(),
|
|
)
|
|
assert resp.status_code == 200
|
|
cfg = storage.load_workstream_config("ws-target")
|
|
stored = cfg.get("close_reason")
|
|
assert stored is not None
|
|
assert secret not in stored
|
|
assert "[REDACTED:" in stored
|
|
|
|
|
|
def test_close_reason_persistence_failure_does_not_block_close(storage):
|
|
"""If the storage save raises, the close still succeeds — persistence
|
|
is best-effort; a transient storage error must not block the user
|
|
from closing a workstream."""
|
|
client = _make_app(storage)
|
|
|
|
def _boom(*args, **kwargs):
|
|
raise RuntimeError("storage down")
|
|
|
|
storage.save_workstream_config = _boom # type: ignore[method-assign]
|
|
resp = client.post(
|
|
"/v1/api/workstreams/ws-target/close",
|
|
json={"reason": "task complete"},
|
|
headers=_full_hdr(),
|
|
)
|
|
assert resp.status_code == 200
|