diff --git a/tests/test_api_versioning.py b/tests/test_api_versioning.py index eb4ca82c..44e18af4 100644 --- a/tests/test_api_versioning.py +++ b/tests/test_api_versioning.py @@ -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") diff --git a/tests/test_auth.py b/tests/test_auth.py index 5092fab4..571ee31e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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", diff --git a/tests/test_auth_identity.py b/tests/test_auth_identity.py index df27d874..20882f62 100644 --- a/tests/test_auth_identity.py +++ b/tests/test_auth_identity.py @@ -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, ) diff --git a/tests/test_close_reason_persistence.py b/tests/test_close_reason_persistence.py index af9eca7a..cc3c394f 100644 --- a/tests/test_close_reason_persistence.py +++ b/tests/test_close_reason_persistence.py @@ -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 diff --git a/tests/test_console_routing_proxy.py b/tests/test_console_routing_proxy.py index 53a21b7c..0a5d9f92 100644 --- a/tests/test_console_routing_proxy.py +++ b/tests/test_console_routing_proxy.py @@ -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}/ (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 diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 8f52daa1..2c86c67b 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -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"] diff --git a/tests/test_route_proxy_audit.py b/tests/test_route_proxy_audit.py index f85fc9ea..d7ca45dd 100644 --- a/tests/test_route_proxy_audit.py +++ b/tests/test_route_proxy_audit.py @@ -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. diff --git a/tests/test_sdk_console.py b/tests/test_sdk_console.py index e395c9b8..b6e5c6ac 100644 --- a/tests/test_sdk_console.py +++ b/tests/test_sdk_console.py @@ -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 diff --git a/tests/test_sdk_server.py b/tests/test_sdk_server.py index be06781e..85251660 100644 --- a/tests/test_sdk_server.py +++ b/tests/test_sdk_server.py @@ -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"} # --------------------------------------------------------------------------- diff --git a/tests/test_server_attachments_endpoints.py b/tests/test_server_attachments_endpoints.py index 13280f43..e5f5b6b6 100644 --- a/tests/test_server_attachments_endpoints.py +++ b/tests/test_server_attachments_endpoints.py @@ -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 diff --git a/tests/test_server_authz.py b/tests/test_server_authz.py index 74e64c92..9ea91862 100644 --- a/tests/test_server_authz.py +++ b/tests/test_server_authz.py @@ -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() diff --git a/tests/test_session_routes.py b/tests/test_session_routes.py index c4e896fe..f03d1d4c 100644 --- a/tests/test_session_routes.py +++ b/tests/test_session_routes.py @@ -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."""