chore(tests): refresh fixtures for path-keyed URL family

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.
This commit is contained in:
Patrick Buckley
2026-04-26 20:49:49 -07:00
parent 0d0628eec4
commit bf55bf54ff
12 changed files with 222 additions and 195 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ class TestServerVersioning:
assert resp.status_code == 200
spec = resp.json()
assert spec["openapi"] == "3.1.0"
assert "/v1/api/send" in spec["paths"]
assert "/v1/api/workstreams/{ws_id}/send" in spec["paths"]
def test_docs_page(self, client):
resp = client.get("/docs")
+51 -27
View File
@@ -71,8 +71,8 @@ class TestIsPublicPath:
def test_v1_api_workstreams_not_public(self):
assert is_public_path("/v1/api/workstreams") is False
def test_v1_api_send_not_public(self):
assert is_public_path("/v1/api/send") is False
def test_v1_api_workstreams_send_not_public(self):
assert is_public_path("/v1/api/workstreams/abc/send") is False
def test_openapi_json_public(self):
assert is_public_path("/openapi.json") is True
@@ -97,10 +97,22 @@ class TestRequiredScope:
assert required_scope("GET", "/api/events") == "read"
def test_post_send_needs_write(self):
assert required_scope("POST", "/api/send") == "write"
assert required_scope("POST", "/api/workstreams/abc/send") == "write"
def test_delete_send_needs_write(self):
assert required_scope("DELETE", "/api/workstreams/abc/send") == "write"
def test_post_approve_needs_approve(self):
assert required_scope("POST", "/api/approve") == "approve"
assert required_scope("POST", "/api/workstreams/abc/approve") == "approve"
def test_post_cancel_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc/cancel") == "write"
def test_post_close_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc/close") == "write"
def test_get_events_per_ws_needs_read(self):
assert required_scope("GET", "/api/workstreams/abc/events") == "read"
def test_post_plan_needs_write(self):
assert required_scope("POST", "/api/plan") == "write"
@@ -111,9 +123,6 @@ class TestRequiredScope:
def test_post_workstreams_new_needs_write(self):
assert required_scope("POST", "/api/workstreams/new") == "write"
def test_post_workstreams_close_needs_write(self):
assert required_scope("POST", "/api/workstreams/close") == "write"
def test_all_write_paths_need_write(self):
for path in WRITE_PATHS:
scope = required_scope("POST", path)
@@ -123,10 +132,10 @@ class TestRequiredScope:
assert required_scope("POST", "/api/unknown") == "read"
def test_v1_post_send_needs_write(self):
assert required_scope("POST", "/v1/api/send") == "write"
assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write"
def test_v1_post_approve_needs_approve(self):
assert required_scope("POST", "/v1/api/approve") == "approve"
assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve"
def test_v1_get_workstreams_needs_read(self):
assert required_scope("GET", "/v1/api/workstreams") == "read"
@@ -135,10 +144,10 @@ class TestRequiredScope:
assert required_scope("POST", "/v1/api/cluster/workstreams/new") == "write"
def test_proxy_v1_send_needs_write(self):
assert required_scope("POST", "/node/node-a/v1/api/send") == "write"
assert required_scope("POST", "/node/node-a/v1/api/workstreams/abc/send") == "write"
def test_proxy_v1_approve_needs_approve(self):
assert required_scope("POST", "/node/node-a/v1/api/approve") == "approve"
assert required_scope("POST", "/node/node-a/v1/api/workstreams/abc/approve") == "approve"
def test_proxy_v1_read_endpoint_needs_read(self):
assert required_scope("GET", "/node/node-a/v1/api/workstreams") == "read"
@@ -402,7 +411,7 @@ class TestCheckRequest:
def test_write_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/send", read_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/send", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
@@ -417,7 +426,7 @@ class TestCheckRequest:
def test_approve_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/approve", read_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/approve", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
@@ -425,7 +434,10 @@ class TestCheckRequest:
def test_proxy_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via proxy routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/api/send", read_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/api/workstreams/abc/send",
read_jwt,
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
@@ -433,7 +445,10 @@ class TestCheckRequest:
def test_proxy_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on proxy routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/api/send/", read_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/api/workstreams/abc/send/",
read_jwt,
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
@@ -441,7 +456,7 @@ class TestCheckRequest:
def test_direct_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on direct routes."""
allowed, status, msg, _result = check_request(
"POST", "/api/send/", read_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/send/", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
@@ -449,14 +464,20 @@ class TestCheckRequest:
def test_proxy_write_full_token_ok(self, full_jwt):
"""Full tokens pass through proxy write routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/api/send", full_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/api/workstreams/abc/send",
full_jwt,
jwt_secret=self._SECRET,
)
assert allowed is True
def test_proxy_v1_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via v1 proxy routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/v1/api/send", read_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/v1/api/workstreams/abc/send",
read_jwt,
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
@@ -464,7 +485,10 @@ class TestCheckRequest:
def test_proxy_v1_write_full_token_ok(self, full_jwt):
"""Full tokens pass through v1 proxy write routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/v1/api/send", full_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/v1/api/workstreams/abc/send",
full_jwt,
jwt_secret=self._SECRET,
)
assert allowed is True
@@ -559,7 +583,7 @@ class TestCheckRequestWithCookie:
def test_cookie_read_on_write_403(self, read_jwt):
allowed, status, _, _r = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
None,
cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
@@ -700,25 +724,25 @@ class TestServerAuth:
def test_api_send_read_token_403(self):
resp = self.client.post(
"/v1/api/send",
"/v1/api/workstreams/x/send",
headers=self._read_hdr,
json={"message": "hello", "ws_id": "x"},
json={"message": "hello"},
)
assert resp.status_code == 403
assert "Forbidden" in resp.json().get("error", "")
def test_api_send_full_token_passes_auth(self):
resp = self.client.post(
"/v1/api/send",
"/v1/api/workstreams/nonexistent/send",
headers=self._full_hdr,
json={"message": "hello", "ws_id": "nonexistent"},
json={"message": "hello"},
)
assert resp.status_code not in (401, 403)
def test_api_send_no_token_401(self):
resp = self.client.post(
"/v1/api/send",
json={"message": "hello", "ws_id": "x"},
"/v1/api/workstreams/x/send",
json={"message": "hello"},
)
assert resp.status_code == 401
@@ -731,7 +755,7 @@ class TestServerAuth:
def test_options_no_auth_required(self):
resp = self.client.options(
"/v1/api/send",
"/v1/api/workstreams/x/send",
headers={
"Origin": "http://example.com",
"Access-Control-Request-Method": "POST",
+11 -11
View File
@@ -175,10 +175,10 @@ class TestRequiredScope:
assert required_scope("GET", "/api/workstreams") == "read"
def test_post_write(self):
assert required_scope("POST", "/api/send") == "write"
assert required_scope("POST", "/api/workstreams/abc/send") == "write"
def test_post_approve(self):
assert required_scope("POST", "/api/approve") == "approve"
assert required_scope("POST", "/api/workstreams/abc/approve") == "approve"
def test_admin_prefix(self):
assert required_scope("GET", "/api/admin/users") == "approve"
@@ -186,14 +186,14 @@ class TestRequiredScope:
assert required_scope("DELETE", "/api/admin/users/abc") == "approve"
def test_versioned_path(self):
assert required_scope("POST", "/v1/api/send") == "write"
assert required_scope("POST", "/v1/api/approve") == "approve"
assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write"
assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve"
def test_proxy_write(self):
assert required_scope("POST", "/node/n1/api/send") == "write"
assert required_scope("POST", "/node/n1/api/workstreams/abc/send") == "write"
def test_proxy_approve(self):
assert required_scope("POST", "/node/n1/api/approve") == "approve"
assert required_scope("POST", "/node/n1/api/workstreams/abc/approve") == "approve"
# ---------------------------------------------------------------------------
@@ -270,7 +270,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -282,7 +282,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/approve",
"/api/workstreams/abc/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -294,7 +294,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
allowed, status, msg, result = check_request(
"POST",
"/api/approve",
"/api/workstreams/abc/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -306,7 +306,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
allowed, status, msg, result = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -318,7 +318,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
+14 -14
View File
@@ -73,8 +73,8 @@ def storage(tmp_path):
def test_close_with_reason_persists_to_workstream_config(storage):
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-target", "reason": "task complete"},
"/v1/api/workstreams/ws-target/close",
json={"reason": "task complete"},
headers=_full_hdr(),
)
assert resp.status_code == 200
@@ -85,8 +85,8 @@ def test_close_with_reason_persists_to_workstream_config(storage):
def test_close_without_reason_does_not_touch_config(storage):
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-target"},
"/v1/api/workstreams/ws-target/close",
json={},
headers=_full_hdr(),
)
assert resp.status_code == 200
@@ -102,8 +102,8 @@ def test_close_reason_capped_at_512_bytes(storage):
huge = "x" * 5000
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-target", "reason": huge},
"/v1/api/workstreams/ws-target/close",
json={"reason": huge},
headers=_full_hdr(),
)
assert resp.status_code == 200
@@ -120,8 +120,8 @@ def test_close_reason_byte_cap_holds_for_multibyte_utf8(storage):
huge = "\u6f22" * 600 # 3 bytes/char in UTF-8
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-target", "reason": huge},
"/v1/api/workstreams/ws-target/close",
json={"reason": huge},
headers=_full_hdr(),
)
assert resp.status_code == 200
@@ -137,8 +137,8 @@ def test_close_with_non_string_reason_drops_silently(storage):
proceeds without writing to workstream_config."""
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-target", "reason": {"unexpected": "shape"}},
"/v1/api/workstreams/ws-target/close",
json={"reason": {"unexpected": "shape"}},
headers=_full_hdr(),
)
assert resp.status_code == 200
@@ -154,8 +154,8 @@ def test_close_reason_redacts_credentials(storage):
client = _make_app(storage)
secret = "AKIAIOSFODNN7EXAMPLE" # AWS access key — output guard catches.
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-target", "reason": f"task done; key={secret}"},
"/v1/api/workstreams/ws-target/close",
json={"reason": f"task done; key={secret}"},
headers=_full_hdr(),
)
assert resp.status_code == 200
@@ -177,8 +177,8 @@ def test_close_reason_persistence_failure_does_not_block_close(storage):
storage.save_workstream_config = _boom # type: ignore[method-assign]
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-target", "reason": "task complete"},
"/v1/api/workstreams/ws-target/close",
json={"reason": "task complete"},
headers=_full_hdr(),
)
assert resp.status_code == 200
+34 -20
View File
@@ -93,6 +93,15 @@ def _wire_proxy(app: Any, mock_post: MagicMock | None = None) -> None:
mock_post = _make_proxy_post()
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = mock_post
# route_proxy uses ``client.request(method, url, ...)`` for path-keyed
# routes (so DELETE on /send proxies through correctly). Wire a
# request-shim that drops the leading method positional and forwards
# to the same mock_post for compatibility.
async def _request_shim(method: str, *args: Any, **kwargs: Any) -> httpx.Response:
return await mock_post(*args, **kwargs)
mock_proxy.request = MagicMock(side_effect=_request_shim)
app.state.proxy_client = mock_proxy
@@ -283,7 +292,8 @@ class TestRouteCreate503Retry:
class TestRouteProxy:
"""POST /v1/api/route/send (and other routed endpoints)."""
"""POST /v1/api/route/workstreams/{ws_id}/<verb> (and the surviving
body-keyed plan/command routes)."""
@pytest.fixture()
def client(self):
@@ -296,29 +306,33 @@ class TestRouteProxy:
def test_route_proxy_send(self, client):
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc123", "message": "hello"},
"/v1/api/route/workstreams/abc123/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
# Verify upstream URL was /v1/api/send (not /v1/api/route/send)
mock_post = client.app.state.proxy_client.post
call_args = mock_post.call_args
assert "/v1/api/send" in call_args[0][0]
assert "/route/" not in call_args[0][0]
# Verify upstream URL was /v1/api/workstreams/abc123/send
# (not /v1/api/route/workstreams/abc123/send).
mock_request = client.app.state.proxy_client.request
call_args = mock_request.call_args
# request is called as ``request(method, url, ...)`` — url is the
# second positional arg.
upstream_url = call_args[0][1]
assert "/v1/api/workstreams/abc123/send" in upstream_url
assert "/route/" not in upstream_url
def test_route_proxy_approve(self, client):
resp = client.post(
"/v1/api/route/approve",
json={"ws_id": "abc123", "approved": True},
"/v1/api/route/workstreams/abc123/approve",
json={"approved": True},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
def test_route_proxy_cancel(self, client):
resp = client.post(
"/v1/api/route/cancel",
json={"ws_id": "abc123"},
"/v1/api/route/workstreams/abc123/cancel",
json={},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -333,8 +347,8 @@ class TestRouteProxy:
def test_route_proxy_close(self, client):
resp = client.post(
"/v1/api/route/workstreams/close",
json={"ws_id": "abc123"},
"/v1/api/route/workstreams/abc123/close",
json={},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -413,8 +427,8 @@ class TestRouteNotReady:
def test_route_proxy_no_router_503(self, client_no_router):
resp = client_no_router.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
@@ -425,8 +439,8 @@ class TestRouteNotReady:
def test_route_proxy_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
@@ -465,8 +479,8 @@ class TestRouteNoNode:
def test_route_proxy_no_node_503(self, client):
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
+6 -5
View File
@@ -28,16 +28,17 @@ class TestServerSpec:
"/v1/api/workstreams",
"/v1/api/workstreams/{ws_id}",
"/v1/api/workstreams/{ws_id}/history",
"/v1/api/workstreams/{ws_id}/send",
"/v1/api/workstreams/{ws_id}/approve",
"/v1/api/workstreams/{ws_id}/cancel",
"/v1/api/workstreams/{ws_id}/close",
"/v1/api/workstreams/{ws_id}/events",
"/v1/api/dashboard",
"/v1/api/workstreams/saved",
"/v1/api/send",
"/v1/api/approve",
"/v1/api/plan",
"/v1/api/command",
"/v1/api/events",
"/v1/api/events/global",
"/v1/api/workstreams/new",
"/v1/api/workstreams/close",
"/v1/api/auth/login",
"/v1/api/auth/logout",
"/health",
@@ -72,7 +73,7 @@ class TestServerSpec:
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
send = spec["paths"]["/v1/api/send"]["post"]
send = spec["paths"]["/v1/api/workstreams/{ws_id}/send"]["post"]
assert "requestBody" in send
assert "application/json" in send["requestBody"]["content"]
+17 -10
View File
@@ -97,8 +97,12 @@ def _make_proxy(status_code: int = 200, body: dict[str, Any] | None = None) -> M
request=httpx.Request("POST", args[0] if args else "http://test"),
)
async def _request(method: str, *args: Any, **kwargs: Any) -> httpx.Response:
return await _post(*args, **kwargs)
proxy = MagicMock(spec=httpx.AsyncClient)
proxy.post = MagicMock(side_effect=_post)
proxy.request = MagicMock(side_effect=_request)
return proxy
@@ -261,12 +265,12 @@ class TestRouteProxyAudit:
@pytest.mark.parametrize(
"path,expected_action",
[
("/v1/api/route/send", "route.workstream.send"),
("/v1/api/route/approve", "route.approve"),
("/v1/api/route/cancel", "route.cancel"),
("/v1/api/route/workstreams/abc123/send", "route.workstream.send"),
("/v1/api/route/workstreams/abc123/approve", "route.approve"),
("/v1/api/route/workstreams/abc123/cancel", "route.cancel"),
("/v1/api/route/command", "route.command"),
("/v1/api/route/plan", "route.plan"),
("/v1/api/route/workstreams/close", "route.workstream.close"),
("/v1/api/route/workstreams/abc123/close", "route.workstream.close"),
],
)
def test_method_to_action_mapping(self, path: str, expected_action: str):
@@ -276,6 +280,9 @@ class TestRouteProxyAudit:
_wire(app, _make_proxy(200, {"status": "ok"}), storage)
client = TestClient(app, raise_server_exceptions=False)
# ws_id in body is still required by the surviving body-keyed
# mounts (/route/plan, /route/command); for the path-keyed
# workstreams routes the proxy reads ws_id from path_params.
resp = client.post(
path,
json={"ws_id": "abc123", "message": "hi"},
@@ -305,8 +312,8 @@ class TestRouteProxyAudit:
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hi"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hi"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 403
@@ -322,8 +329,8 @@ class TestRouteProxyAudit:
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hi"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hi"},
headers=_PLAIN_HEADERS,
)
assert resp.status_code == 200
@@ -402,8 +409,8 @@ class TestAuditResilience:
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hi"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hi"},
headers=_COORD_HEADERS,
)
# Audit failure is swallowed — proxied response still 200.
+28 -22
View File
@@ -517,43 +517,46 @@ async def test_route_send():
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.route_send("Hello", "ws1")
assert resp["status"] == "ok"
assert captured["path"] == "/v1/api/route/send"
assert captured["body"] == {"message": "Hello", "ws_id": "ws1"}
assert captured["path"] == "/v1/api/route/workstreams/ws1/send"
assert captured["body"] == {"message": "Hello"}
@pytest.mark.anyio
async def test_route_approve():
captured_body: dict = {}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_body.update(json.loads(request.content))
captured["path"] = request.url.path
captured["body"] = json.loads(request.content)
return _json_response({"status": "ok"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
await client.route_approve(ws_id="ws1", approved=False, feedback="no", always=True)
assert captured_body["ws_id"] == "ws1"
assert captured_body["approved"] is False
assert captured_body["feedback"] == "no"
assert captured_body["always"] is True
assert captured["path"] == "/v1/api/route/workstreams/ws1/approve"
assert captured["body"] == {
"approved": False,
"feedback": "no",
"always": True,
}
@pytest.mark.anyio
async def test_route_approve_omits_defaults():
captured_body: dict = {}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_body.update(json.loads(request.content))
captured["path"] = request.url.path
captured["body"] = json.loads(request.content)
return _json_response({"status": "ok"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
await client.route_approve(ws_id="ws1", approved=True)
assert captured_body == {"ws_id": "ws1", "approved": True}
assert "feedback" not in captured_body
assert "always" not in captured_body
assert captured["path"] == "/v1/api/route/workstreams/ws1/approve"
assert captured["body"] == {"approved": True}
@pytest.mark.anyio
@@ -587,39 +590,42 @@ async def test_route_close():
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.route_close("ws1")
assert resp["status"] == "ok"
assert captured["path"] == "/v1/api/route/workstreams/close"
assert captured["body"] == {"ws_id": "ws1"}
assert captured["path"] == "/v1/api/route/workstreams/ws1/close"
assert captured["body"] == {}
@pytest.mark.anyio
async def test_route_cancel():
captured_body: dict = {}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_body.update(json.loads(request.content))
captured["path"] = request.url.path
captured["body"] = json.loads(request.content)
return _json_response({"status": "ok"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
await client.route_cancel("ws1", force=True)
assert captured_body == {"ws_id": "ws1", "force": True}
assert captured["path"] == "/v1/api/route/workstreams/ws1/cancel"
assert captured["body"] == {"force": True}
@pytest.mark.anyio
async def test_route_cancel_omits_force_when_false():
captured_body: dict = {}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_body.update(json.loads(request.content))
captured["path"] = request.url.path
captured["body"] = json.loads(request.content)
return _json_response({"status": "ok"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
await client.route_cancel("ws1")
assert captured_body == {"ws_id": "ws1"}
assert "force" not in captured_body
assert captured["path"] == "/v1/api/route/workstreams/ws1/cancel"
assert captured["body"] == {}
@pytest.mark.anyio
+13 -5
View File
@@ -99,7 +99,7 @@ async def test_create_workstream():
@pytest.mark.anyio
async def test_close_workstream():
transport = _mock_transport(
{"POST /v1/api/workstreams/close": _json_response({"status": "ok"})}
{"POST /v1/api/workstreams/ws1/close": _json_response({"status": "ok"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
@@ -114,7 +114,9 @@ async def test_close_workstream():
@pytest.mark.anyio
async def test_send():
transport = _mock_transport({"POST /v1/api/send": _json_response({"status": "ok"})})
transport = _mock_transport(
{"POST /v1/api/workstreams/ws1/send": _json_response({"status": "ok"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.send("Hello", "ws1")
@@ -123,7 +125,9 @@ async def test_send():
@pytest.mark.anyio
async def test_approve():
transport = _mock_transport({"POST /v1/api/approve": _json_response({"status": "ok"})})
transport = _mock_transport(
{"POST /v1/api/workstreams/ws1/approve": _json_response({"status": "ok"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.approve(ws_id="ws1", approved=True, feedback="looks good")
@@ -238,7 +242,11 @@ async def test_health():
@pytest.mark.anyio
async def test_api_error_raised():
transport = _mock_transport(
{"POST /v1/api/send": httpx.Response(404, json={"error": "Unknown workstream"})}
{
"POST /v1/api/workstreams/bad_ws/send": httpx.Response(
404, json={"error": "Unknown workstream"}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
@@ -279,7 +287,7 @@ async def test_request_body_correct():
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
await client.send("Hello world", "ws_123")
assert captured_body == {"message": "Hello world", "ws_id": "ws_123"}
assert captured_body == {"message": "Hello world"}
# ---------------------------------------------------------------------------
+29 -32
View File
@@ -463,8 +463,8 @@ class TestSendMessageAttachments:
aid = _upload(client, "ws-A", "userA", "n.md", b"hi", "text/markdown")
resp = client.post(
"/v1/api/send",
json={"message": "review", "ws_id": "ws-A", "attachment_ids": [aid]},
"/v1/api/workstreams/ws-A/send",
json={"message": "review", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -489,8 +489,8 @@ class TestSendMessageAttachments:
_upload(client, "ws-A", "userA", "b.md", b"B", "text/markdown")
resp = client.post(
"/v1/api/send",
json={"message": "do", "ws_id": "ws-A"},
"/v1/api/workstreams/ws-A/send",
json={"message": "do"},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -510,8 +510,8 @@ class TestSendMessageAttachments:
_upload(client, "ws-A", "userA", "a.md", b"A", "text/markdown")
resp = client.post(
"/v1/api/send",
json={"message": "plain", "ws_id": "ws-A", "attachment_ids": []},
"/v1/api/workstreams/ws-A/send",
json={"message": "plain", "attachment_ids": []},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -533,10 +533,9 @@ class TestSendMessageAttachments:
# Request order: c, a, b — must be preserved through resolution
resp = client.post(
"/v1/api/send",
"/v1/api/workstreams/ws-A/send",
json={
"message": "ordered",
"ws_id": "ws-A",
"attachment_ids": [c, a, b],
},
headers=_auth("userA"),
@@ -561,8 +560,8 @@ class TestSendMessageAttachments:
too_many = [f"id-{i}" for i in range(MAX_PENDING_ATTACHMENTS_PER_USER_WS + 1)]
resp = client.post(
"/v1/api/send",
json={"message": "x", "ws_id": "ws-A", "attachment_ids": too_many},
"/v1/api/workstreams/ws-A/send",
json={"message": "x", "attachment_ids": too_many},
headers=_auth("userA"),
)
assert resp.status_code == 400
@@ -580,10 +579,9 @@ class TestSendMessageAttachments:
captured, _ = self._wire_ws(mgr, "ws-A", "userA")
resp = client.post(
"/v1/api/send",
"/v1/api/workstreams/ws-A/send",
json={
"message": "sneaky",
"ws_id": "ws-A",
"attachment_ids": [stolen_id],
},
headers=_auth("userA"),
@@ -651,10 +649,9 @@ class TestQueuedSendWithAttachments:
b = _upload(client, "ws-A", "userA", "b.md", b"B", "text/markdown")
resp = client.post(
"/v1/api/send",
"/v1/api/workstreams/ws-A/send",
json={
"message": "ping",
"ws_id": "ws-A",
"attachment_ids": [b, a], # intentionally reversed
},
headers=_auth("userA"),
@@ -714,8 +711,8 @@ class TestQueuedAttachmentReservation:
aid = _upload(client, ws_id, "userA", filename, b"Q", "text/markdown")
ws, session = self._wire_busy_ws(mgr, ws_id)
resp = client.post(
"/v1/api/send",
json={"message": "queued", "ws_id": ws_id, "attachment_ids": [aid]},
f"/v1/api/workstreams/{ws_id}/send",
json={"message": "queued", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -767,8 +764,8 @@ class TestQueuedAttachmentReservation:
# Auto-consume on a follow-up send: reserved attachment must not
# be picked up (another turn isn't entitled to it).
resp = client.post(
"/v1/api/send",
json={"message": "follow up", "ws_id": "ws-A"},
"/v1/api/workstreams/ws-A/send",
json={"message": "follow up"},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -799,8 +796,8 @@ class TestQueuedAttachmentReservation:
# A second send explicitly naming the reserved id: scope check
# rejects it, so the attachment list is empty.
resp = client.post(
"/v1/api/send",
json={"message": "take mine", "ws_id": "ws-A", "attachment_ids": [aid]},
"/v1/api/workstreams/ws-A/send",
json={"message": "take mine", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -823,8 +820,8 @@ class TestQueuedAttachmentReservation:
# Cancel the queued message — DELETE /api/send with msg_id
resp = client.request(
"DELETE",
"/v1/api/send",
json={"ws_id": "ws-A", "msg_id": mid},
"/v1/api/workstreams/ws-A/send",
json={"msg_id": mid},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -895,8 +892,8 @@ class TestReserveThenDispatchRace:
# First send — reserves A under its send_id, worker blocks
resp1 = client.post(
"/v1/api/send",
json={"message": "one", "ws_id": "ws-A", "attachment_ids": [aid]},
"/v1/api/workstreams/ws-A/send",
json={"message": "one", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp1.status_code == 200
@@ -924,8 +921,8 @@ class TestReserveThenDispatchRace:
session.send = second_send # type: ignore[method-assign]
resp2 = client.post(
"/v1/api/send",
json={"message": "two", "ws_id": "ws-A", "attachment_ids": [aid]},
"/v1/api/workstreams/ws-A/send",
json={"message": "two", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp2.status_code == 200
@@ -961,8 +958,8 @@ class TestReserveThenDispatchRace:
session.send = exploding_send # type: ignore[method-assign]
resp = client.post(
"/v1/api/send",
json={"message": "boom", "ws_id": "ws-A", "attachment_ids": [aid]},
"/v1/api/workstreams/ws-A/send",
json={"message": "boom", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -1005,8 +1002,8 @@ class TestReserveThenDispatchRace:
ws_tuple[1].send = fake_send # type: ignore[method-assign]
resp = client.post(
"/v1/api/send",
json={"message": "both", "ws_id": "ws-A", "attachment_ids": [a, b]},
"/v1/api/workstreams/ws-A/send",
json={"message": "both", "attachment_ids": [a, b]},
headers=_auth("userA"),
)
assert resp.status_code == 200
@@ -1068,8 +1065,8 @@ class TestServiceScopedActorFlow:
"userA",
)
resp = client.post(
"/v1/api/send",
json={"message": "svc send", "ws_id": "ws-A", "attachment_ids": [aid]},
"/v1/api/workstreams/ws-A/send",
json={"message": "svc send", "attachment_ids": [aid]},
headers=svc_headers,
)
assert resp.status_code == 200
+18 -25
View File
@@ -328,8 +328,8 @@ class TestCrossTenantApprove:
assert storage is not None
_register_ws(storage, "ws-victim", "victim-user")
resp = client.post(
"/v1/api/approve",
json={"ws_id": "ws-victim", "approved": True},
"/v1/api/workstreams/ws-victim/approve",
json={"approved": True},
headers=_auth("attacker-user"),
)
assert resp.status_code == 404
@@ -344,8 +344,8 @@ class TestCrossTenantClose:
assert storage is not None
_register_ws(storage, "ws-victim", "victim-user")
resp = client.post(
"/v1/api/workstreams/close",
json={"ws_id": "ws-victim"},
"/v1/api/workstreams/ws-victim/close",
json={},
headers=_auth("attacker-user"),
)
assert resp.status_code == 404
@@ -632,7 +632,7 @@ class TestPerWsSseGate:
assert storage is not None
_register_ws(storage, "ws-victim", "victim-user")
resp = client.get(
"/v1/api/events?ws_id=ws-victim",
"/v1/api/workstreams/ws-victim/events",
headers=_auth("attacker-user"),
)
assert resp.status_code == 404
@@ -688,8 +688,8 @@ class TestInteractiveCancelLifted:
client, _mgr = app_client
ws_id = self._create_ws(client)
resp = client.post(
"/v1/api/cancel",
json={"ws_id": ws_id},
f"/v1/api/workstreams/{ws_id}/cancel",
json={},
headers=_auth("user-1"),
)
assert resp.status_code == 200
@@ -717,8 +717,8 @@ class TestInteractiveCancelLifted:
ws.worker_thread = threading.Thread(target=lambda: None, daemon=True)
resp = client.post(
"/v1/api/cancel",
json={"ws_id": ws_id, "force": True},
f"/v1/api/workstreams/{ws_id}/cancel",
json={"force": True},
headers=_auth("user-1"),
)
assert resp.status_code == 200
@@ -740,8 +740,8 @@ class TestInteractiveCancelLifted:
ws.session = None # force the build-failed shape
resp = client.post(
"/v1/api/cancel",
json={"ws_id": ws_id},
f"/v1/api/workstreams/{ws_id}/cancel",
json={},
headers=_auth("user-1"),
)
assert resp.status_code == 400
@@ -874,22 +874,15 @@ class TestInteractiveEventsLifted:
out = list(_interactive_events_replay(ws, ui, request))
assert out == []
def test_events_legacy_query_keyed_url_still_resolves_to_404_for_unknown_ws(self, app_client):
"""The legacy ``GET /api/events?ws_id=...`` URL (interactive
stable surface since 1.0) routes through the
``make_legacy_query_keyed_adapter`` shim into the lifted
body. Pinning a 404 response confirms the shim wires
``ws_id`` from query into ``path_params`` correctly — if the
shim were broken, the lifted body's
``request.path_params.get('ws_id', '')`` would return empty
and we'd see a 400 (``ws_id is required``) instead."""
def test_events_path_keyed_url_resolves_to_404_for_unknown_ws(self, app_client):
"""``GET /v1/api/workstreams/{ws_id}/events`` returns 404 for an
unknown ws_id. Pre-1.5 the same intent was tested against
``GET /api/events?ws_id=...`` via the legacy query-keyed
adapter; that URL family was removed in 1.5 along with the
adapter."""
client, _mgr = app_client
resp = client.get(
"/v1/api/events?ws_id=does-not-exist",
"/v1/api/workstreams/does-not-exist/events",
headers=_auth("user-1"),
)
# 404 because the workstream isn't loaded; NOT 400 (which
# would indicate the shim failed to splice ws_id into
# path_params).
assert resp.status_code == 404
assert "ws_id" not in resp.json().get("error", "").lower()
-23
View File
@@ -147,29 +147,6 @@ def test_send_mounts_post_and_delete_when_dequeue_provided() -> None:
assert ("/api/workstreams/{ws_id}/send", frozenset({"POST"})) not in paths_dequeue_only
def test_close_legacy_mounts_when_handler_provided() -> None:
"""The legacy body-keyed close (``POST {prefix}/close``) mounts
when ``handlers.close_legacy`` is non-``None`` — there is no
separate config flag, just the handler's presence."""
routes_with: list[Any] = []
register_session_routes(
routes_with,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(close_legacy=_stub),
)
assert any(r.path == "/api/workstreams/close" for r in routes_with if isinstance(r, Route))
routes_without: list[Any] = []
register_session_routes(
routes_without,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(),
)
assert not any(
r.path == "/api/workstreams/close" for r in routes_without if isinstance(r, Route)
)
def test_register_coord_verbs_mounts_seven_paths() -> None:
"""``register_coord_verbs`` mounts the seven coord-only verbs
at the unified prefix."""