Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
"""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
|
|
|
|
|
|
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
|
|
|
_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",
|
|
|
|
|
]
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
|
|
|
|
|
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:
|
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
|
|
|
for migration in REPO_ROOT_MIGRATIONS:
|
|
|
|
|
conn.execute(migration.read_text(encoding="utf-8"))
|
Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00
|
|
|
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()
|