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.
181 lines
6.5 KiB
Python
181 lines
6.5 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_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()
|