target-revenue/tests/test_registry_hosting.py
tegwick 7986e62f31 Extend WP-0006 auth for per-human sub-credentials (WP-0009-T02)
migrations/0005_licensor_credentials.sql: licensors can now hold
multiple rows per licensor_id (credential_label, rights tier,
issued_by, revoked_at). Real structural finding: licensor_id couldn't
simply become non-unique, since phase_manifests, extensions, and
breach_records all FK to licensors(licensor_id), which only worked
because that column used to be unique. Introduced licensor_identities
(one row per tenant) as the new FK target for all four tables, with an
ensure_licensor_identity trigger auto-creating the identity on first
credential insert - so existing code (including every earlier test
fixture) needed no changes.

registry.py: Licensor gains credential_label/rights; RIGHTS_TIERS +
has_right() ordinal helper (enforcement is Control Plane's job, T03/
T04, not this task's); issue_sub_credential/revoke_sub_credential
(revocation via a SECURITY DEFINER function, matching
set_extension_status's existing pattern - trf_app has no UPDATE grant
on licensors); authenticate() rejects revoked credentials identically
to unrecognized ones.

Attribution scoped honestly: ledger_entry.schema.json stays unmodified
(frozen Stage 0 surface, additionalProperties:false) - per-entry human
attribution is a hosting-layer-only column
(ledger_entries.submitted_by_token, ledger.get_ledger_attribution()),
recorded alongside but never inside the signed entry payload. Narrower
than "the signature names the human," but exactly the "(or an
accompanying attributable field)" alternative this task's own
description anticipated.

All four Docker-gated test files that append Ledger entries needed
migration 0005 added (append_entry's INSERT now references the new
column). New tests/test_licensor_credentials.py (8 tests): multi-
credential resolution, duplicate-label rejection, revocation and its
idempotence, invalid-rights rejection, the has_right helper, per-entry
attribution recorded and not leaking into exported ledger JSON, and
DB-level UPDATE rejection. Full suite: 84 offline, 41 with Docker (up
from 30); no stray containers left running.
2026-07-30 14:26:33 +02:00

184 lines
6.7 KiB
Python

"""Integration tests for WP-0006-T03 (hosted Phase/Extension Registry).
Spins up an ephemeral, disposable PostgreSQL container via `docker run`
(never the shared `infra-postgres-1`/`custodian` instance used by the state
hub), applies `migrations/0001_registries.sql`, and exercises the hosted
API through FastAPI's TestClient. Skipped automatically if Docker is not
available, so the offline WP-0002 suite (`tests/test_*.py`) is unaffected.
Requires the `service-dev` extra: `pip install -e ".[service-dev]"`.
"""
from __future__ import annotations
import shutil
import subprocess
import time
import uuid
import pytest
psycopg = pytest.importorskip("psycopg")
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from conftest import golden_extension, golden_manifest # noqa: E402
_REPO_ROOT = __import__("pathlib").Path(__file__).resolve().parents[1]
REPO_ROOT_MIGRATIONS = [
_REPO_ROOT / "migrations" / "0001_registries.sql",
_REPO_ROOT / "migrations" / "0005_licensor_credentials.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-{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 REPO_ROOT_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"),
)
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}
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"])
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
with TestClient(app_module.app) as c:
yield c
def auth_headers(pg_container):
return {"Authorization": f"Bearer {pg_container['token']}"}
def test_register_and_read_golden_manifest(client, pg_container):
manifest = golden_manifest()
resp = client.post("/phases", json=manifest, headers=auth_headers(pg_container))
assert resp.status_code == 201, resp.text
assert resp.json()["status"] == "registered"
read = client.get(f"/phases/{manifest['phase']['id']}")
assert read.status_code == 200
assert read.json() == manifest
def test_reject_non_conformant_manifest(client, pg_container):
broken = golden_manifest()
del broken["phase"]["initial_target"]
resp = client.post("/phases", json=broken, headers=auth_headers(pg_container))
assert resp.status_code == 422
assert "initial_target" in resp.json()["detail"]
def test_cannot_reregister_same_phase_id(client, pg_container):
manifest = golden_manifest()
manifest["phase"]["id"] = manifest["phase"]["id"] + "-dup-test"
first = client.post("/phases", json=manifest, headers=auth_headers(pg_container))
assert first.status_code == 201
second = client.post("/phases", json=manifest, headers=auth_headers(pg_container))
assert second.status_code == 422
assert "already registered" in second.json()["detail"]
def test_unknown_token_rejected(client):
manifest = golden_manifest()
resp = client.post(
"/phases", json=manifest, headers={"Authorization": "Bearer not-a-real-token"}
)
assert resp.status_code == 401
def test_register_extension_and_promote_canonical(client, pg_container):
ext = golden_extension("development-license")
resp = client.post("/extensions", json=ext, headers=auth_headers(pg_container))
assert resp.status_code == 201, resp.text
assert resp.json()["status"] == "registered"
read = client.get(f"/extensions/{ext['id']}/{ext['version']}")
assert read.json()["status"] == "registered"
import psycopg as _psycopg
from target_revenue import registry as registry_module
with _psycopg.connect(pg_container["admin_dsn"]) as admin_conn:
registry_module.promote_extension_canonical(
admin_conn, ext["id"], ext["version"], approved_by="Bernd"
)
admin_conn.commit()
read_after = client.get(f"/extensions/{ext['id']}/{ext['version']}")
assert read_after.json()["status"] == "canonical"
def test_application_role_cannot_update_or_delete_settled_manifest(pg_container):
"""Database-level enforcement check (ADR-0002 compensating guardrail 3):
the trf_app role must have no UPDATE/DELETE grant on phase_manifests at
all, independent of what the API layer happens to expose."""
with psycopg.connect(pg_container["app_dsn"]) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute(
"UPDATE phase_manifests SET manifest = manifest WHERE phase_id = 'nonexistent'"
)
conn.rollback()
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM phase_manifests WHERE phase_id = 'nonexistent'")
conn.rollback()
def test_application_role_cannot_update_extensions_directly(pg_container):
with psycopg.connect(pg_container["app_dsn"]) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute(
"UPDATE extensions SET status = 'canonical' WHERE extension_id = 'nonexistent'"
)
conn.rollback()