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.
This commit is contained in:
parent
e8e8629efd
commit
7e0c62a8b5
8 changed files with 580 additions and 2 deletions
181
tests/test_registry_hosting.py
Normal file
181
tests/test_registry_hosting.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""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_MIGRATIONS = (
|
||||
__import__("pathlib").Path(__file__).resolve().parents[1] / "migrations" / "0001_registries.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:
|
||||
conn.execute(REPO_ROOT_MIGRATIONS.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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue