target-revenue/tests/test_ledger_hosting.py
tegwick b7b985d96d Implement hosted Breach/Compliance Record publication (WP-0006-T09)
migrations/0004_breach_records.sql models a case's lifecycle as
append-only events (alleged/cured/determined/terminated) grouped by
case_id rather than one mutable row - resolution is always a new,
later event, never an edit. A CHECK constraint makes the
anonymized-default rule (License V1C1 §7.4) a database fact:
named_entitlement_holder can be set if and only if anonymized = false.

src/target_revenue/breach_record.py's publish_breach_event() enforces
per-Licensor phase ownership and rejects named-disclosure requests
that don't also set named_disclosure_authorized_under_cua: true - the
Trust Service records the Licensor's assertion that the CUA's naming
clause authorizes it, it never verifies the underlying CUA text
itself. Signs every event with the same instance Ed25519 key already
used for Ledger entries and Attestations.

Adds POST/GET /phases/{id}/breach-records. Guarded the .registry
import behind a lazy in-function import (matching attestation.py's
TYPE_CHECKING pattern) so tests/test_breach_record.py (7 tests) runs
under plain system Python with no psycopg dependency. 5 new
Docker-gated tests cover the default-anonymized lifecycle, the
named-disclosure authorization gate, cross-Licensor rejection,
signature verification, and DB-level UPDATE/DELETE rejection.

This closes WP-0006 again - all 9 tasks done.
2026-07-29 22:23:41 +02:00

523 lines
22 KiB
Python

"""Integration tests for WP-0006-T04 (hosted Target Ledger append API).
Same ephemeral, disposable Postgres-via-Docker pattern as
test_registry_hosting.py (never the shared state-hub instance). The single
property this file tests most aggressively, per the task's own framing:
appending entries through the hosted API and then folding the exported
result offline (`fold.fold_outstanding_target`) must produce exactly the
same Outstanding Target a hosted metrics/attestation component would later
report — a hosted service that silently diverges from the offline fold is
the highest-risk defect this component could introduce.
"""
from __future__ import annotations
import shutil
import subprocess
import time
import uuid
from pathlib import Path
import pytest
psycopg = pytest.importorskip("psycopg")
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from conftest import golden_manifest # noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = [
REPO_ROOT / "migrations" / "0001_registries.sql",
REPO_ROOT / "migrations" / "0002_ledger.sql",
REPO_ROOT / "migrations" / "0003_attestations.sql",
REPO_ROOT / "migrations" / "0004_breach_records.sql",
]
pytestmark = pytest.mark.skipif(
shutil.which("docker") is None, reason="docker not available"
)
@pytest.fixture(scope="module")
def pg_container():
name = f"trf-test-pg-ledger-{uuid.uuid4().hex[:8]}"
subprocess.run(
[
"docker", "run", "--rm", "-d",
"--name", name,
"-e", "POSTGRES_PASSWORD=postgres",
"-e", "POSTGRES_DB=target_revenue_test",
"-p", "127.0.0.1::5432",
"postgres:16-alpine",
],
check=True, capture_output=True,
)
try:
port_out = subprocess.run(
["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True
).stdout.strip()
host_port = port_out.split(":")[-1]
dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres"
for _ in range(60):
try:
with psycopg.connect(dsn, connect_timeout=1):
break
except psycopg.OperationalError:
time.sleep(0.5)
else:
raise RuntimeError("postgres container did not become ready in time")
with psycopg.connect(dsn) as conn:
for migration in MIGRATIONS:
conn.execute(migration.read_text(encoding="utf-8"))
conn.commit()
token = "test-token-acme"
conn.execute(
"INSERT INTO licensors (token, licensor_id) VALUES (%s, %s)",
(token, "acme-corp"),
)
other_token = "test-token-other"
conn.execute(
"INSERT INTO licensors (token, licensor_id) VALUES (%s, %s)",
(other_token, "other-corp"),
)
conn.commit()
app_dsn = (
f"host=127.0.0.1 port={host_port} dbname=target_revenue_test "
f"user=trf_app password=changeme-in-deployment"
)
yield {
"admin_dsn": dsn,
"app_dsn": app_dsn,
"token": token,
"other_token": other_token,
}
finally:
subprocess.run(["docker", "stop", name], capture_output=True)
@pytest.fixture()
def client(pg_container, monkeypatch):
monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"])
monkeypatch.setenv("TRF_SIGNING_KEY_HEX", "11" * 32)
from target_revenue.service import app as app_module
if hasattr(app_module.app.state, "pool"):
app_module.app.state.pool.close()
del app_module.app.state.pool
if hasattr(app_module.app.state, "signing_key"):
del app_module.app.state.signing_key
with TestClient(app_module.app) as c:
yield c
def auth_headers(token: str):
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def registered_phase(client, pg_container):
manifest = golden_manifest()
manifest["phase"]["id"] = manifest["phase"]["id"] + "-ledger-test-" + uuid.uuid4().hex[:6]
resp = client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 201, resp.text
return manifest
def _entry(phase_id: str, entry_id: str, entry_type: str, amount: float, recognized_at: str):
return {
"id": entry_id,
"phase": phase_id,
"type": entry_type,
"amount": amount,
"currency": "USD",
"recognized_at": recognized_at,
"evidence_reference": f"confidential:evidence:{entry_id}",
"extension": {"id": "trsl:extension:development-license", "version": "1.0"},
}
def test_append_and_hash_chain_links(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e1 = _entry(phase_id, "trsl:entry:ledgertest0001", "development-credit", 1000, "2026-08-01T00:00:00Z")
r1 = client.post(f"/phases/{phase_id}/ledger", json=e1, headers=auth_headers(pg_container["token"]))
assert r1.status_code == 201, r1.text
stored1 = r1.json()
assert stored1["previous_entry_hash"] == "GENESIS"
assert "signature" in stored1
e2 = _entry(phase_id, "trsl:entry:ledgertest0002", "development-credit", 500, "2026-08-02T00:00:00Z")
r2 = client.post(f"/phases/{phase_id}/ledger", json=e2, headers=auth_headers(pg_container["token"]))
assert r2.status_code == 201, r2.text
stored2 = r2.json()
from target_revenue import hashing
assert stored2["previous_entry_hash"] == hashing.entry_hash(stored1)
# Full chain, as exported, must independently verify.
hashing.verify_chain([stored1, stored2])
def test_client_cannot_supply_previous_entry_hash_or_signature(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
forged = _entry(phase_id, "trsl:entry:ledgertest0003", "development-credit", 1, "2026-08-01T00:00:00Z")
forged["previous_entry_hash"] = "GENESIS"
resp = client.post(f"/phases/{phase_id}/ledger", json=forged, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 422
assert "must not be supplied" in resp.json()["detail"]
def test_other_licensor_cannot_append_to_this_phase(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertest0004", "development-credit", 1, "2026-08-01T00:00:00Z")
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["other_token"]))
assert resp.status_code == 422
assert "not authorized" in resp.json()["detail"]
def test_currency_mismatch_rejected(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertest0005", "development-credit", 1, "2026-08-01T00:00:00Z")
e["currency"] = "EUR"
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 422
assert "currency" in resp.json()["detail"]
def test_hosted_ledger_export_folds_identically_to_offline_computation(
client, pg_container, registered_phase
):
"""The property T04's task description calls out as highest-risk:
hosted append + export, folded offline, must match what was appended."""
from target_revenue import fold, hashing
phase_id = registered_phase["phase"]["id"]
plan = [
("development-credit", 25000),
("development-credit", 10000),
("remission-credit", 5000),
("credit-reversal", 2000),
]
for i, (entry_type, amount) in enumerate(plan, start=1):
e = _entry(
phase_id,
f"trsl:entry:ledgertestfold{i:04d}",
entry_type,
amount,
f"2026-08-0{i}T00:00:00Z",
)
if entry_type == "credit-reversal":
e["reverses"] = "trsl:entry:ledgertestfold0001"
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 201, resp.text
export = client.get(f"/phases/{phase_id}/ledger")
assert export.status_code == 200
entries = export.json()
assert len(entries) == len(plan)
# Chain integrity survives the round trip through Postgres/JSONB/API.
hashing.verify_chain(entries)
result = fold.fold_outstanding_target(
registered_phase["phase"]["initial_target"]["amount"], entries
)
expected_development_credit = 25000 + 10000 - 2000
expected_remission_credit = 5000
assert result.development_credit == expected_development_credit
assert result.remission_credit == expected_remission_credit
assert result.outstanding_target == max(
0.0,
registered_phase["phase"]["initial_target"]["amount"]
- expected_development_credit
- expected_remission_credit,
)
def test_public_key_endpoint_unauthenticated_and_verifies_signature(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestpk0001", "development-credit", 1, "2026-08-01T00:00:00Z")
stored = client.post(
f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])
).json()
pk_resp = client.get("/public-key")
assert pk_resp.status_code == 200
assert pk_resp.json()["algorithm"] == "ed25519"
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from target_revenue import hashing
public_key = Ed25519PublicKey.from_public_bytes(
bytes.fromhex(pk_resp.json()["public_key_hex"])
)
assert hashing.verify_record_signature(stored, stored["signature"], public_key)
def test_application_role_cannot_update_or_delete_ledger_entries(pg_container):
with psycopg.connect(pg_container["app_dsn"]) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("UPDATE ledger_entries SET signature = 'x' WHERE entry_id = 'nonexistent'")
conn.rollback()
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM ledger_entries WHERE entry_id = 'nonexistent'")
conn.rollback()
def test_duplicate_entry_id_rejected(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestdup0001", "development-credit", 1, "2026-08-01T00:00:00Z")
first = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert first.status_code == 201
second = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert second.status_code == 422
assert "already exists" in second.json()["detail"]
def test_metrics_endpoint_unauthenticated_and_matches_offline_computation(
client, pg_container, registered_phase
):
"""WP-0006-T05: the hosted /metrics response must match what
target_revenue.metrics.compute_metrics computes offline from the same
exported Manifest + Ledger — the metrics-layer analogue of T04's
hosted/offline fold-agreement test."""
from target_revenue import metrics as metrics_module
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestmetrics0001", "development-credit", 4000, "2026-08-01T00:00:00Z")
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 201, resp.text
# No Authorization header at all: metrics are public per FR-9/FR-10.
metrics_resp = client.get(f"/phases/{phase_id}/metrics")
assert metrics_resp.status_code == 200
hosted = metrics_resp.json()
entries = client.get(f"/phases/{phase_id}/ledger").json()
as_of = metrics_module.datetime.fromisoformat(hosted["as_of"])
offline = metrics_module.compute_metrics(registered_phase, entries, as_of)
assert hosted == offline
assert hosted["facts"]["cumulative_development_credit"] == 4000
def test_attestation_not_available_before_conversion(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestattest0001", "development-credit", 1, "2026-08-01T00:00:00Z")
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 201
metrics_resp = client.get(f"/phases/{phase_id}/metrics")
assert metrics_resp.json()["facts"]["is_converted"] is False
attestation_resp = client.get(f"/phases/{phase_id}/attestation")
assert attestation_resp.status_code == 404
def test_conversion_is_true_independent_of_attestation_call(client, pg_container):
"""TSD §3.5 legal-technical rule: conversion is already true from the
ledger the instant the fold reaches zero; nothing about calling (or not
calling) /attestation may change what /metrics already reports."""
manifest = golden_manifest()
manifest["phase"]["id"] = manifest["phase"]["id"] + "-attest-conv-" + uuid.uuid4().hex[:6]
manifest["phase"]["initial_target"]["amount"] = 1000
client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])).raise_for_status()
phase_id = manifest["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestattest0002", "development-credit", 1000, "2026-08-01T00:00:00Z")
client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])).raise_for_status()
before = client.get(f"/phases/{phase_id}/metrics").json()
assert before["facts"]["is_converted"] is True
assert before["facts"]["outstanding_target"] == 0
# No attestation has been requested yet; conversion is already true.
attestation_resp = client.get(f"/phases/{phase_id}/attestation")
assert attestation_resp.status_code == 200
after = client.get(f"/phases/{phase_id}/metrics").json()
assert after["facts"]["is_converted"] is True
# Publishing the attestation must change nothing about the conversion
# facts/calculations/forecasts themselves (only `as_of` legitimately
# differs, since /metrics stamps wall-clock time on every call).
assert after["facts"] == before["facts"]
assert after["calculations"] == before["calculations"]
assert after["forecasts"] == before["forecasts"]
def test_attestation_published_once_and_idempotent_on_reread(client, pg_container):
manifest = golden_manifest()
manifest["phase"]["id"] = manifest["phase"]["id"] + "-attest-idem-" + uuid.uuid4().hex[:6]
manifest["phase"]["initial_target"]["amount"] = 500
client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])).raise_for_status()
phase_id = manifest["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestattest0003", "development-credit", 500, "2026-08-01T00:00:00Z")
client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])).raise_for_status()
first = client.get(f"/phases/{phase_id}/attestation")
assert first.status_code == 200
second = client.get(f"/phases/{phase_id}/attestation")
assert second.status_code == 200
assert first.json() == second.json() # same signed record, not regenerated
from target_revenue import validation
validation.validate_conversion_attestation(
{k: v for k, v in first.json().items() if k != "signature"}
)
assert first.json()["conversion_timestamp"] == "2026-08-01T00:00:00Z"
assert first.json()["ledger_checkpoint"] == "trsl:entry:ledgertestattest0003"
def test_attestation_verifiable_with_public_key(client, pg_container):
manifest = golden_manifest()
manifest["phase"]["id"] = manifest["phase"]["id"] + "-attest-verify-" + uuid.uuid4().hex[:6]
manifest["phase"]["initial_target"]["amount"] = 500
client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])).raise_for_status()
phase_id = manifest["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestattest0004", "development-credit", 500, "2026-08-01T00:00:00Z")
client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])).raise_for_status()
published = client.get(f"/phases/{phase_id}/attestation").json()
pk_hex = client.get("/public-key").json()["public_key_hex"]
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from target_revenue import hashing
public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pk_hex))
assert hashing.verify_record_signature(published, published["signature"], public_key)
def test_application_role_cannot_update_or_delete_attestations(pg_container):
with psycopg.connect(pg_container["app_dsn"]) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("UPDATE attestations SET signature = 'x' WHERE phase_id = 'nonexistent'")
conn.rollback()
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM attestations WHERE phase_id = 'nonexistent'")
conn.rollback()
def _breach_event(event_id: str, case_id: str, event_type: str, **overrides) -> dict:
event = {
"id": event_id,
"case_id": case_id,
"event_type": event_type,
"category": "unauthorized-commercial-use",
"event_at": "2026-08-01T00:00:00Z",
}
event.update(overrides)
return event
def test_breach_record_defaults_anonymized_and_lists_lifecycle_events(
client, pg_container, registered_phase
):
phase_id = registered_phase["phase"]["id"]
case_id = "trsl:case:conformcase0001"
alleged = client.post(
f"/phases/{phase_id}/breach-records",
json=_breach_event("trsl:breach:conformcase00010001", case_id, "alleged"),
headers=auth_headers(pg_container["token"]),
)
assert alleged.status_code == 201, alleged.text
assert alleged.json()["anonymized"] is True
assert alleged.json()["named_entitlement_holder"] is None
assert "signature" in alleged.json()
determined = client.post(
f"/phases/{phase_id}/breach-records",
json=_breach_event(
"trsl:breach:conformcase00010002", case_id, "determined",
event_at="2026-08-15T00:00:00Z",
),
headers=auth_headers(pg_container["token"]),
)
assert determined.status_code == 201, determined.text
records = client.get(f"/phases/{phase_id}/breach-records")
assert records.status_code == 200
events = records.json()
assert [e["event_type"] for e in events] == ["alleged", "determined"]
# The "alleged" event must never have been edited by the "determined"
# publication — both remain, in order, as separate records.
assert events[0]["id"] == "trsl:breach:conformcase00010001"
assert events[1]["id"] == "trsl:breach:conformcase00010002"
def test_breach_record_named_disclosure_requires_explicit_cua_authorization(
client, pg_container, registered_phase
):
phase_id = registered_phase["phase"]["id"]
unauthorized = client.post(
f"/phases/{phase_id}/breach-records",
json=_breach_event(
"trsl:breach:conformnamed0001", "trsl:case:conformnamed", "alleged",
anonymized=False, named_entitlement_holder="Acme Corp",
),
headers=auth_headers(pg_container["token"]),
)
assert unauthorized.status_code == 422
assert "named_disclosure_authorized_under_cua" in unauthorized.json()["detail"]
authorized = client.post(
f"/phases/{phase_id}/breach-records",
json=_breach_event(
"trsl:breach:conformnamed0002", "trsl:case:conformnamed", "alleged",
anonymized=False,
named_entitlement_holder="Acme Corp",
named_disclosure_authorized_under_cua=True,
),
headers=auth_headers(pg_container["token"]),
)
assert authorized.status_code == 201, authorized.text
assert authorized.json()["named_entitlement_holder"] == "Acme Corp"
def test_other_licensor_cannot_publish_breach_record_for_this_phase(
client, pg_container, registered_phase
):
phase_id = registered_phase["phase"]["id"]
resp = client.post(
f"/phases/{phase_id}/breach-records",
json=_breach_event("trsl:breach:conformcross0001", "trsl:case:conformcross", "alleged"),
headers=auth_headers(pg_container["other_token"]),
)
assert resp.status_code == 422
assert "not authorized" in resp.json()["detail"]
def test_breach_record_signature_verifiable_with_public_key(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
published = client.post(
f"/phases/{phase_id}/breach-records",
json=_breach_event("trsl:breach:conformverify0001", "trsl:case:conformverify", "alleged"),
headers=auth_headers(pg_container["token"]),
).json()
pk_hex = client.get("/public-key").json()["public_key_hex"]
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from target_revenue import hashing
public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pk_hex))
assert hashing.verify_record_signature(published, published["signature"], public_key)
def test_application_role_cannot_update_or_delete_breach_records(pg_container):
with psycopg.connect(pg_container["app_dsn"]) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("UPDATE breach_records SET category = 'x' WHERE record_id = 'nonexistent'")
conn.rollback()
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM breach_records WHERE record_id = 'nonexistent'")
conn.rollback()