"""Tests for canonical KB claim routes.""" from __future__ import annotations import asyncio import json import sys import threading import time from pathlib import Path import pytest pytest.importorskip("aiohttp") from aiohttp import web from aiohttp.test_utils import make_mocked_request REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "diagnostics")) import app as argus_app # noqa: E402 import kb_claim_routes as routes # noqa: E402 CLAIM_ID = "d0000000-0000-0000-0000-000000000005" CONNECTED_ID = "e0000000-0000-0000-0000-000000000006" SECOND_CLAIM_ID = "c0000000-0000-0000-0000-000000000004" THIRD_CLAIM_ID = "b0000000-0000-0000-0000-000000000003" API_KEY = "test-argus-api-key" def _claim_list_app() -> web.Application: app = web.Application() app[routes.KB_CLAIM_LIST_API_KEY] = API_KEY return app def _claim_list_request(path: str, *, app: web.Application): return make_mocked_request("GET", path, app=app, headers={"X-Api-Key": API_KEY}) def _claim_data(): return { "claim": { "id": CLAIM_ID, "type": "strategy_kernel", "text": "Claims should be falsifiable enough to support review.", "status": "open", "confidence": "0.72", "tags": ["claims", "review"], "created_at": "2026-07-09T00:00:00Z", "updated_at": "2026-07-09T00:00:00Z", }, "evidence": [ { "role": "grounds", "weight": "0.8", "source_type": "telegram", "url": "https://example.test/source", "storage_path": None, "excerpt": "m3taversal asked Leo to split a broad claim into atomic claims.", } ], "edges": [ { "direction": "outgoing", "edge_type": "supports", "connected_id": CONNECTED_ID, "connected_text": "Reviewable claims need supporting evidence and edges.", "connected_status": "open", } ], } def _claim_summary(claim_id: str, *, timestamp: str, text: str = "Canonical claim"): return { "id": claim_id, "type": "strategy_kernel", "text": text, "text_truncated": False, "status": "open", "confidence": "0.72", "tags": ["claims", "review"], "created_at": timestamp, "updated_at": timestamp, "evidence_count": 3, "edge_count": 5, "_cursor_timestamp": timestamp, "internal_secret": "must-not-leak", } def test_claim_link_html_links_valid_claim_ids() -> None: html = routes.claim_link_html(CLAIM_ID) assert f'href="/kb/claims/{CLAIM_ID}"' in html assert f"{CLAIM_ID}" in html def test_render_kb_claim_page_shows_body_evidence_and_linked_edges() -> None: html = routes.render_kb_claim_page(_claim_data()) assert "Claims should be falsifiable enough to support review." in html assert "m3taversal asked Leo" in html assert f'href="/kb/claims/{CONNECTED_ID}"' in html assert "supports" in html assert "open" in html def test_api_kb_claim_uses_loader_and_returns_claim_graph() -> None: app = web.Application() app[routes.KB_CLAIM_LOADER_KEY] = lambda claim_id: _claim_data() if claim_id == CLAIM_ID else None req = make_mocked_request( "GET", f"/api/kb/claims/{CLAIM_ID}", app=app, match_info={"claim_id": CLAIM_ID}, ) response = asyncio.run(routes.handle_api_kb_claim(req)) data = json.loads(response.body.decode()) assert response.status == 200 assert data["claim"]["id"] == CLAIM_ID assert data["edges"][0]["connected_id"] == CONNECTED_ID assert response.headers["Cache-Control"] == "private, no-store, max-age=0" def test_claim_cursor_round_trip() -> None: timestamp = "2026-07-13T21:12:00+00:00" cursor = routes.encode_claim_cursor(timestamp, CLAIM_ID) assert routes.decode_claim_cursor(cursor) == (timestamp, CLAIM_ID) def test_canonical_claim_list_database_role_fails_closed(monkeypatch, tmp_path) -> None: monkeypatch.delenv("KB_CLAIM_BROWSER_ROLE", raising=False) monkeypatch.delenv("KB_CLAIM_BROWSER_SECRETS_FILE", raising=False) default_secrets_file = tmp_path / "missing-kb-observatory-read-password" monkeypatch.setattr(routes, "CLAIM_LIST_DEFAULT_SECRETS_FILE", str(default_secrets_file)) with pytest.raises(RuntimeError, match="read role is not configured"): routes._claim_list_db_args() default_secrets_file.write_text("PGPASSWORD=not-printed\n", encoding="utf-8") default_secrets_file.chmod(0o600) default_args = routes._claim_list_db_args() assert default_args.role == routes.CLAIM_LIST_REQUIRED_ROLE assert default_args.secrets_file == str(default_secrets_file) monkeypatch.setenv("KB_CLAIM_BROWSER_ROLE", "kb_apply") monkeypatch.setenv("KB_CLAIM_BROWSER_SECRETS_FILE", "/run/secrets/read-password") with pytest.raises(RuntimeError, match=routes.CLAIM_LIST_REQUIRED_ROLE): routes._claim_list_db_args() monkeypatch.setenv("KB_CLAIM_BROWSER_ROLE", routes.CLAIM_LIST_REQUIRED_ROLE) args = routes._claim_list_db_args() assert args.role == routes.CLAIM_LIST_REQUIRED_ROLE assert args.secrets_file == "/run/secrets/read-password" def test_canonical_claim_list_database_session_must_be_read_only(monkeypatch, tmp_path) -> None: secrets_file = tmp_path / "kb-observatory-read.env" secrets_file.write_text("PGPASSWORD=not-printed\n", encoding="utf-8") secrets_file.chmod(0o640) monkeypatch.setenv("KB_CLAIM_BROWSER_ROLE", routes.CLAIM_LIST_REQUIRED_ROLE) monkeypatch.setenv("KB_CLAIM_BROWSER_SECRETS_FILE", str(secrets_file)) observed_sql = {} def run_psql(_args, sql, _password): observed_sql["value"] = sql return json.dumps({"session_read_only": "off", "total": 0, "rows": []}) monkeypatch.setattr(routes, "_run_claim_list_psql", run_psql) filters = { "q": "", "status": "all", "type": "", "tag": "", "limit": 25, "cursor": "", "cursor_values": None, } with pytest.raises(RuntimeError, match="session is not read-only"): routes._load_claim_list_from_db(filters) assert "current_setting('transaction_read_only')" in observed_sql["value"] assert "set statement_timeout = '5000ms'" in observed_sql["value"] assert observed_sql["value"].index("page_rows as") < observed_sql["value"].index( "from public.claim_evidence" ) def test_canonical_claim_list_secrets_file_accepts_only_pgpassword(tmp_path) -> None: secrets_file = tmp_path / "kb-observatory-read.env" secrets_file.write_text("# dedicated read role\nPGPASSWORD='read-only-secret'\n", encoding="utf-8") secrets_file.chmod(0o640) assert routes._load_claim_list_password(str(secrets_file)) == "read-only-secret" secrets_file.write_text("KB_APPLY_PASSWORD=wrong-boundary\n", encoding="utf-8") with pytest.raises(RuntimeError, match="invalid format"): routes._load_claim_list_password(str(secrets_file)) secrets_file.write_text("PGPASSWORD=secret\n", encoding="utf-8") secrets_file.chmod(0o644) with pytest.raises(RuntimeError, match="world-accessible"): routes._load_claim_list_password(str(secrets_file)) def test_canonical_claim_list_process_timeout_is_enforced_and_sanitized(monkeypatch) -> None: args = routes.argparse.Namespace( container="postgres", db="teleo", host="127.0.0.1", role=routes.CLAIM_LIST_REQUIRED_ROLE, ) observed = {} def timeout_run(command, **kwargs): observed["command"] = command observed.update(kwargs) raise routes.subprocess.TimeoutExpired(command, kwargs["timeout"], stderr="secret") monkeypatch.setattr(routes.subprocess, "run", timeout_run) with pytest.raises(TimeoutError, match="database query timed out") as exc_info: routes._run_claim_list_psql(args, "select 1", "must-not-leak") assert "secret" not in str(exc_info.value) assert observed["timeout"] == routes.CLAIM_LIST_PROCESS_TIMEOUT_SECONDS assert f"PGCONNECT_TIMEOUT={routes.CLAIM_LIST_CONNECT_TIMEOUT_SECONDS}" in observed[ "command" ] def test_canonical_claim_list_api_key_loads_only_from_dedicated_file( monkeypatch, tmp_path ) -> None: api_key_file = tmp_path / "kb-observatory-api-key" api_key_file.write_text("route-scoped-observatory-key\n", encoding="utf-8") api_key_file.chmod(0o640) monkeypatch.setenv("KB_CLAIM_BROWSER_API_KEY_FILE", str(api_key_file)) assert routes.load_claim_list_api_key() == "route-scoped-observatory-key" api_key_file.write_text("two tokens\n", encoding="utf-8") assert routes.load_claim_list_api_key() is None api_key_file.write_text("é" * 24, encoding="utf-8") assert routes.load_claim_list_api_key() is None api_key_file.write_text("route-scoped-observatory-key\n", encoding="utf-8") api_key_file.chmod(0o644) assert routes.load_claim_list_api_key() is None def test_canonical_claim_list_is_versioned_paginated_and_sanitized() -> None: app = _claim_list_app() captured_filters = {} def loader(filters): captured_filters.update(filters) return { "total": 1837, "rows": [ _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00"), _claim_summary(SECOND_CLAIM_ID, timestamp="2026-07-13T20:12:00+00:00"), ], } app[routes.KB_CLAIM_LIST_LOADER_KEY] = loader req = _claim_list_request( "/api/kb/claims?limit=1&q=falsifiable&type=strategy_kernel&tag=review", app=app, ) response = asyncio.run(routes.handle_api_kb_claims(req)) data = json.loads(response.body.decode()) assert response.status == 200 assert response.headers["Cache-Control"] == "private, no-store, max-age=0" assert response.headers["Vary"] == "X-Api-Key" assert data["schema"] == routes.CLAIM_LIST_SCHEMA assert data["read_only"] is True assert data["source"]["store"] == "canonical_postgres" assert data["page"] == { "limit": 1, "returned": 1, "total": 1837, "has_more": True, "next_cursor": data["page"]["next_cursor"], } assert routes.decode_claim_cursor(data["page"]["next_cursor"])[1] == CLAIM_ID assert data["claims"][0]["id"] == CLAIM_ID assert "internal_secret" not in data["claims"][0] assert captured_filters["q"] == "falsifiable" assert captured_filters["type"] == "strategy_kernel" assert captured_filters["tag"] == "review" def test_canonical_claim_list_caps_response_bytes_without_skipping_rows(monkeypatch) -> None: app = _claim_list_app() rows = [ _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00", text="🔥" * 1200), _claim_summary( SECOND_CLAIM_ID, timestamp="2026-07-13T20:12:00+00:00", text="🔥" * 1200, ), _claim_summary( THIRD_CLAIM_ID, timestamp="2026-07-13T19:12:00+00:00", text="🔥" * 1200, ), ] for row in rows: row["tags"] = ["🔥" * 128] * 32 app[routes.KB_CLAIM_LIST_LOADER_KEY] = lambda _filters: {"total": 3, "rows": rows} assert routes.CLAIM_LIST_MAX_RESPONSE_BYTES < 512_000 byte_limit = 100_000 monkeypatch.setattr(routes, "CLAIM_LIST_MAX_RESPONSE_BYTES", byte_limit) request = _claim_list_request("/api/kb/claims?limit=3&status=all", app=app) response = asyncio.run(routes.handle_api_kb_claims(request)) data = json.loads(response.body.decode()) assert response.status == 200 assert len(response.body) <= byte_limit assert data["page"]["limit"] == 3 assert data["page"]["returned"] == 1 assert data["page"]["has_more"] is True assert routes.decode_claim_cursor(data["page"]["next_cursor"])[1] == CLAIM_ID assert [claim["id"] for claim in data["claims"]] == [CLAIM_ID] def test_canonical_claim_list_clamps_limit_and_rejects_bad_cursor() -> None: app = _claim_list_app() observed = {} app[routes.KB_CLAIM_LIST_LOADER_KEY] = lambda filters: observed.update(filters) or {"total": 0, "rows": []} large_req = _claim_list_request("/api/kb/claims?limit=999", app=app) large_response = asyncio.run(routes.handle_api_kb_claims(large_req)) large_data = json.loads(large_response.body.decode()) assert large_response.status == 200 assert large_data["page"]["limit"] == routes.CLAIM_LIST_MAX_LIMIT assert observed["limit"] == routes.CLAIM_LIST_MAX_LIMIT bad_req = _claim_list_request("/api/kb/claims?cursor=not-a-cursor", app=app) bad_response = asyncio.run(routes.handle_api_kb_claims(bad_req)) bad_data = json.loads(bad_response.body.decode()) assert bad_response.status == 400 assert bad_data == {"error": "invalid cursor"} long_req = _claim_list_request(f"/api/kb/claims?cursor={'x' * 513}", app=app) long_response = asyncio.run(routes.handle_api_kb_claims(long_req)) assert long_response.status == 400 def test_canonical_claim_list_failure_is_sanitized() -> None: app = _claim_list_app() def fail(_filters): raise SystemExit("database password would have appeared here") app[routes.KB_CLAIM_LIST_LOADER_KEY] = fail req = _claim_list_request("/api/kb/claims", app=app) response = asyncio.run(routes.handle_api_kb_claims(req)) data = json.loads(response.body.decode()) assert response.status == 503 assert data == {"error": "canonical_claims_unavailable"} assert b"password" not in response.body def test_canonical_claim_auth_boundaries_are_exact() -> None: list_path = "/api/kb/claims" assert list_path in routes.KB_CLAIM_SELF_AUTH_PATHS assert f"{list_path}/{CLAIM_ID}" not in routes.KB_CLAIM_SELF_AUTH_PATHS assert f"/api/kb/claims/{CLAIM_ID}".startswith(routes.KB_CLAIM_GLOBAL_AUTH_PREFIXES) assert f"/kb/claims/{CLAIM_ID}".startswith(routes.KB_CLAIM_GLOBAL_AUTH_PREFIXES) def test_canonical_claim_list_authentication_fails_closed() -> None: app_without_key = web.Application() unconfigured_req = make_mocked_request("GET", "/api/kb/claims", app=app_without_key) unconfigured_response = asyncio.run(routes.handle_api_kb_claims(unconfigured_req)) assert unconfigured_response.status == 503 assert json.loads(unconfigured_response.body.decode()) == {"error": "canonical_claims_auth_unconfigured"} configured_app = _claim_list_app() unauthorized_req = make_mocked_request("GET", "/api/kb/claims", app=configured_app) unauthorized_response = asyncio.run(routes.handle_api_kb_claims(unauthorized_req)) assert unauthorized_response.status == 401 assert json.loads(unauthorized_response.body.decode()) == {"error": "unauthorized"} non_ascii_request = make_mocked_request( "GET", "/api/kb/claims", app=configured_app, headers={"X-Api-Key": "é" * 24}, ) non_ascii_response = asyncio.run(routes.handle_api_kb_claims(non_ascii_request)) assert non_ascii_response.status == 401 assert json.loads(non_ascii_response.body.decode()) == {"error": "unauthorized"} def test_argus_middleware_only_bypasses_global_key_for_exact_list_path() -> None: async def ok_handler(_request): return web.Response(status=204) app = web.Application() list_request = make_mocked_request("GET", "/api/kb/claims", app=app) detail_request = make_mocked_request( "GET", f"/api/kb/claims/{CLAIM_ID}", app=app ) html_request = make_mocked_request("GET", f"/kb/claims/{CLAIM_ID}", app=app) list_response = asyncio.run(argus_app.auth_middleware(list_request, ok_handler)) detail_response = asyncio.run(argus_app.auth_middleware(detail_request, ok_handler)) html_response = asyncio.run(argus_app.auth_middleware(html_request, ok_handler)) assert list_response.status == 204 assert detail_response.status == 503 assert html_response.status == 503 def test_canonical_claim_list_loader_runs_off_the_event_loop() -> None: app = _claim_list_app() event_loop_thread = threading.get_ident() loader_thread = None def loader(_filters): nonlocal loader_thread loader_thread = threading.get_ident() return {"total": 0, "rows": []} app[routes.KB_CLAIM_LIST_LOADER_KEY] = loader request = _claim_list_request("/api/kb/claims", app=app) response = asyncio.run(routes.handle_api_kb_claims(request)) assert response.status == 200 assert loader_thread is not None assert loader_thread != event_loop_thread def test_canonical_claim_list_loader_timeout_is_sanitized(monkeypatch) -> None: app = _claim_list_app() def slow_loader(_filters): time.sleep(0.03) return {"total": 0, "rows": []} app[routes.KB_CLAIM_LIST_LOADER_KEY] = slow_loader monkeypatch.setattr(routes, "CLAIM_LIST_HANDLER_TIMEOUT_SECONDS", 0.001) request = _claim_list_request("/api/kb/claims", app=app) response = asyncio.run(routes.handle_api_kb_claims(request)) assert response.status == 503 assert json.loads(response.body.decode()) == {"error": "canonical_claims_unavailable"} def test_canonical_claim_list_rejects_duplicate_or_out_of_order_pages() -> None: filters = { "q": "", "status": "all", "type": "", "tag": "", "limit": 2, "cursor": "", "cursor_values": None, } newest = _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00") older = _claim_summary(SECOND_CLAIM_ID, timestamp="2026-07-13T20:12:00+00:00") with pytest.raises(ValueError, match="duplicate ids"): routes._claim_list_payload({"total": 2, "rows": [newest, newest]}, filters) with pytest.raises(ValueError, match="strictly ordered"): routes._claim_list_payload({"total": 2, "rows": [older, newest]}, filters) def test_canonical_claim_list_rejects_rows_at_or_before_wrong_cursor_side() -> None: cursor_timestamp = "2026-07-13T20:12:00+00:00" filters = { "q": "", "status": "all", "type": "", "tag": "", "limit": 1, "cursor": routes.encode_claim_cursor(cursor_timestamp, SECOND_CLAIM_ID), "cursor_values": (cursor_timestamp, SECOND_CLAIM_ID), } newer = _claim_summary(CLAIM_ID, timestamp="2026-07-13T21:12:00+00:00") with pytest.raises(ValueError, match="crossed its request cursor"): routes._claim_list_payload({"total": 2, "rows": [newer]}, filters) def test_legacy_claim_errors_do_not_expose_exception_text() -> None: app = web.Application() def fail(_claim_id): raise RuntimeError("PGPASSWORD=must-not-leak") app[routes.KB_CLAIM_LOADER_KEY] = fail api_request = make_mocked_request( "GET", f"/api/kb/claims/{CLAIM_ID}", app=app, match_info={"claim_id": CLAIM_ID}, ) html_request = make_mocked_request( "GET", f"/kb/claims/{CLAIM_ID}", app=app, match_info={"claim_id": CLAIM_ID}, ) api_response = asyncio.run(routes.handle_api_kb_claim(api_request)) html_response = asyncio.run(routes.handle_kb_claim_page(html_request)) assert api_response.status == 503 assert html_response.status == 500 assert b"PGPASSWORD" not in api_response.body assert b"PGPASSWORD" not in html_response.body