target-revenue/tests/test_onboarding_hosted.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

175 lines
5.9 KiB
Python

"""End-to-end test: scripts/trf_onboard.py against a real, running hosted
Trust Service instance (WP-0006-T07), not just FastAPI's in-process
TestClient — this is the one test in the suite that exercises an actual
HTTP round trip over a real socket, since the onboarding CLI uses
`urllib` against a real URL rather than an ASGI transport.
Same ephemeral, disposable Postgres-via-Docker pattern as the other
hosted tests (never the shared state-hub instance).
"""
from __future__ import annotations
import json
import shutil
import subprocess
import threading
import time
import uuid
from pathlib import Path
import pytest
psycopg = pytest.importorskip("psycopg")
pytest.importorskip("fastapi")
uvicorn = pytest.importorskip("uvicorn")
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",
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-onboard-{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-onboard"
conn.execute(
"INSERT INTO licensors (token, licensor_id) VALUES (%s, %s)",
(token, "onboard-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 {"app_dsn": app_dsn, "token": token}
finally:
subprocess.run(["docker", "stop", name], capture_output=True)
@pytest.fixture()
def live_server(pg_container, monkeypatch):
monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"])
monkeypatch.setenv("TRF_SIGNING_KEY_HEX", "22" * 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
config = uvicorn.Config(app_module.app, host="127.0.0.1", port=0, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(100):
if getattr(server, "started", False):
break
time.sleep(0.05)
else:
raise RuntimeError("uvicorn server did not start in time")
port = server.servers[0].sockets[0].getsockname()[1]
try:
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
thread.join(timeout=5)
def test_onboard_cli_registers_phase_and_appends_entry_against_live_server(
live_server, pg_container, tmp_path, monkeypatch, capsys
):
import importlib.util
spec = importlib.util.spec_from_file_location("trf_onboard", REPO_ROOT / "scripts" / "trf_onboard.py")
trf_onboard = importlib.util.module_from_spec(spec)
spec.loader.exec_module(trf_onboard)
manifest = golden_manifest()
manifest["phase"]["id"] = manifest["phase"]["id"] + "-onboard-" + uuid.uuid4().hex[:6]
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
monkeypatch.setenv("TRF_ONBOARD_TEST_TOKEN", pg_container["token"])
trf_onboard.main(
[
"register-phase",
"--url", live_server,
"--token-env", "TRF_ONBOARD_TEST_TOKEN",
"--manifest", str(manifest_path),
]
)
entry = {
"id": "trsl:entry:onboardcli0001",
"phase": manifest["phase"]["id"],
"type": "development-credit",
"amount": 1000,
"currency": "USD",
"recognized_at": "2026-08-01T00:00:00Z",
"evidence_reference": "confidential:evidence:onboardcli0001",
"extension": {"id": "trsl:extension:development-license", "version": "1.0"},
}
entry_path = tmp_path / "entry.json"
entry_path.write_text(json.dumps(entry), encoding="utf-8")
trf_onboard.main(
[
"append-entry",
"--url", live_server,
"--token-env", "TRF_ONBOARD_TEST_TOKEN",
"--phase-id", manifest["phase"]["id"],
"--entry", str(entry_path),
]
)
capsys.readouterr() # discard register-phase/append-entry output
trf_onboard.main(["status", "--url", live_server, "--phase-id", manifest["phase"]["id"]])
status = json.loads(capsys.readouterr().out)
assert status["facts"]["cumulative_development_credit"] == 1000