Skip to content
Merged
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.d/2.12.6-api-route-contract-tests.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
seonghobae marked this conversation as resolved.
_require_post_read(account)
client = _post_chat_client()
if not client.available:
Expand Down
149 changes: 148 additions & 1 deletion backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import os
import uuid
from contextlib import closing
from pathlib import Path

import jwt
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down