diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..fe386a4e9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -195,7 +195,9 @@ it fetches a genuine access token from a live Keycloak, verifies the allow/deny ABAC boundary against a throwaway migrated Postgres database (a private post scoped to a *different* corporate entity is proven excluded from the list and 403s on direct fetch), and proves a forged -token is rejected. `scripts/seed_demo_data.py` populates the docker-compose +token is rejected. Its dev-only FastAPI `TestClient` uses Starlette with the +project's official `httpx` dev dependency; no alternate transport package is +introduced. `scripts/seed_demo_data.py` populates the docker-compose stack itself with the same shape of synthetic data for manual/frontend use. `CORSMiddleware` (`backend/app/main.py`) allows exactly the frontend's origin(s) (`FRONTEND_ORIGINS`), `GET` and `POST` (the extract-keymen diff --git a/CHANGELOG.d/2.12.6-api-route-contract-tests.md b/CHANGELOG.d/2.12.6-api-route-contract-tests.md new file mode 100644 index 000000000..525d150fa --- /dev/null +++ b/CHANGELOG.d/2.12.6-api-route-contract-tests.md @@ -0,0 +1,10 @@ +# Verify buyer API failure and authorization boundaries + +Exercise Ask Agent, RankWeave rankings, and saved locale preferences through +their authenticated HTTP routes. Rankings now have deterministic accepted and +unavailable contract evidence, including proof that another entity's private +post does not enter a buyer's channels. Ask Agent's explicit empty-question +response now uses FastAPI's current `HTTP_422_UNPROCESSABLE_CONTENT` status +name without changing the wire status. Backend integration tests keep +Starlette's TestClient on the project's official `httpx` dev dependency; no +alternate look-alike transport package is introduced. diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..207f69a4b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2621,7 +2621,7 @@ async def ask_agent( """Answer a buyer question from authorized post and graph evidence.""" question = request.question.strip() if not question: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required") _require_post_read(account) client = _post_chat_client() if not client.available: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..e5901ee13 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -14,6 +14,7 @@ import os import uuid +from contextlib import closing from pathlib import Path import jwt @@ -1138,6 +1139,117 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> No ) +def test_update_me_preferences_persists_a_supported_locale( + client, demo_analyst_token, seeded_db +) -> None: + """A supported Buyer locale round-trips without leaking into later tests.""" + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + me_before = client.get("/api/me", headers=headers).json() + original = me_before["preferred_locale"] + updated = "en" if original == "ko" else "ko" + try: + response = client.patch( + "/api/me/preferences", + json={"preferred_locale": updated}, + headers=headers, + ) + assert response.status_code == 200 + assert response.json() == {"preferred_locale": updated} + + me_response = client.get("/api/me", headers=headers) + assert me_response.json()["preferred_locale"] == updated + finally: + with closing(psycopg2.connect(seeded_db["dsn"])) as conn: + with conn, conn.cursor() as cur: + cur.execute( + "update user_account set preferred_locale = %s where user_account_id = %s", + (original, me_before["user_account_id"]), + ) + + +def test_update_me_preferences_rejects_an_unsupported_locale(client, demo_analyst_token) -> None: + """An unsupported locale is rejected and cannot change the saved preference.""" + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + original = client.get("/api/me", headers=headers).json()["preferred_locale"] + response = client.patch( + "/api/me/preferences", + json={"preferred_locale": "fr"}, + headers=headers, + ) + assert response.status_code == 422 + assert client.get("/api/me", headers=headers).json()["preferred_locale"] == original + + +def test_update_me_preferences_requires_authentication(client) -> None: + """Anonymous callers cannot write an account-scoped locale preference.""" + response = client.patch("/api/me/preferences", json={"preferred_locale": "ko"}) + assert response.status_code in (401, 403) + + +def test_rankings_fail_closed_payload_is_exact( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A missing RankWeave transport is unavailable, never ambiguous success.""" + from lineageweave.rankweave_client import RankWeaveClient + + monkeypatch.setattr("backend.app.main._rankweave_client", RankWeaveClient) + response = client.get("/api/rankings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert response.status_code == 200 + assert response.json() == { + "port": "rankweave", + "status": "unavailable", + "status_reason": "rankweave_not_available", + "rankings": [], + } + + +def test_rankings_accept_only_abac_visible_posts( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A deterministic RankWeave adapter ranks visible synthetic posts only.""" + from lineageweave.rankweave_client import RankWeaveClient + + captured_channels: dict[str, list[str]] = {} + + def fuse_visible( + channels: dict[str, list[str]], _weights: dict[str, float] + ) -> list[dict[str, str]]: + captured_channels.update(channels) + return [{"item_id": post_id} for post_id in channels["temporal"]] + + monkeypatch.setattr( + "backend.app.main._rankweave_client", + lambda: RankWeaveClient(transport=fuse_visible), + ) + response = client.get( + "/api/rankings", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["port"] == "rankweave" + assert body["status"] == "accepted" + assert body["status_reason"] is None + assert [row["fused_rank"] for row in body["rankings"]] == list( + range(1, len(body["rankings"]) + 1) + ) + visible_by_id = {row["post_id"]: row for row in body["rankings"]} + assert visible_by_id[seeded_db["own_private_post_id"]]["post_title"] == ( + "Own-corp private post" + ) + assert visible_by_id[seeded_db["public_post_id"]]["post_title"] == "Public post" + assert seeded_db["other_private_post_id"] not in visible_by_id + assert seeded_db["other_private_post_id"] not in captured_channels["temporal"] + assert "theta" not in str(body).lower() + + +def test_rankings_requires_authentication(client) -> None: + """Anonymous callers cannot enumerate the buyer's visible ranking corpus.""" + response = client.get("/api/rankings") + assert response.status_code in (401, 403) + + def test_customer_master_returns_authorized_catalog_contract(client, demo_analyst_token, seeded_db) -> None: admin_conn = psycopg2.connect(seeded_db["dsn"]) try: @@ -1967,7 +2079,11 @@ def test_missing_token_is_unauthorized(client) -> None: def test_forged_token_is_rejected(client) -> None: - forged = jwt.encode({"sub": "not-a-real-subject", "iss": f"{_KEYCLOAK_BASE_URL}/realms/{_REALM}"}, key="wrong-key", algorithm="HS256") + forged = jwt.encode( + {"sub": "not-a-real-subject", "iss": f"{_KEYCLOAK_BASE_URL}/realms/{_REALM}"}, + key="synthetic-wrong-signing-key-32bytes", + algorithm="HS256", + ) response = client.get("/api/posts", headers={"Authorization": f"Bearer {forged}"}) assert response.status_code == 401 @@ -4137,6 +4253,37 @@ def test_derive_commitment_unavailable_without_orchestrator( assert response.status_code == 503 +def test_ask_rejects_an_empty_question(client, demo_analyst_token, seeded_db) -> None: + """Whitespace is not a buyer question and is rejected before orchestration.""" + response = client.post( + "/api/ask", + json={"question": " "}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 422 + + +def test_ask_is_unavailable_without_orchestrator_credentials( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Null chat client must 503, not invent an answer.""" + from lineageweave.post_chat import NullPostChatClient + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: NullPostChatClient()) + response = client.post( + "/api/ask", + json={"question": "What happened with the public post?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 503 + + +def test_ask_requires_authentication(client) -> None: + """Anonymous callers cannot ask across the buyer's authorized post corpus.""" + response = client.post("/api/ask", json={"question": "Any question"}) + assert response.status_code in (401, 403) + + def test_derive_commitment_uses_post_created_at_and_does_not_duplicate( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: