teleo-infrastructure/tests/test_kb_claim_routes.py
Fawaz 6483c7b164
Some checks are pending
CI / lint-and-test (push) Waiting to run
Add protected canonical claims read endpoint for the Observatory (#139)
* Add protected canonical claims read API

* Normalize claim loader exits in worker thread

* Allow file-gated claims reader defaults
2026-07-14 00:18:14 -04:00

540 lines
19 KiB
Python

"""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"<code>{CLAIM_ID}</code>" 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 "<code>supports</code>" in html
assert "<code>open</code>" 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