target-revenue/tests/test_control_plane_app.py
tegwick 3064c0fe0c Go-live T05 + WP-0013/0014: first Phase and Control Plane completion
Accept WP-0008-T05 for trsl:phase:info-tech-canon-service-surface
(history/260805-T05-GoLive-info-tech-canon.md). Finish WP-0013 remission
automation and WP-0014 extension/breach/attestation Control Plane UI.
Update SCOPE, README, and pilot-candidate notes for pilot Stage 1.
2026-08-05 16:00:06 +02:00

582 lines
20 KiB
Python

"""HTTP-level integration tests for the Control Plane interactive UI
(WP-0009-T04), via FastAPI's TestClient.
Same ephemeral, disposable Postgres-via-Docker pattern as
`test_control_plane.py` (never the shared state-hub instance).
Scope disclosure: these tests exercise the FastAPI app's routing, form
handling, session auth, and rights gating at the HTTP layer — they do not
render or interact with the pages in a real browser, since no
browser-automation tool is available in this environment. The
whynot-design web components (`<wn-*>` custom elements) are not
themselves exercised; only the server-rendered HTML/session/redirect
behavior around them is.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
import uuid
from pathlib import Path
import pytest
psycopg = pytest.importorskip("psycopg")
pytest.importorskip("jinja2")
pytest.importorskip("itsdangerous")
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",
REPO_ROOT / "migrations" / "0006_control_plane.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-cpapp-{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 admin_conn:
for migration in MIGRATIONS:
admin_conn.execute(migration.read_text(encoding="utf-8"))
admin_conn.commit()
admin_conn.execute(
"INSERT INTO licensors (token, licensor_id, credential_label, rights, issued_by) "
"VALUES (%s, %s, %s, %s, %s)",
("founding-admin-token", "binky", "founder", "admin", "bootstrap"),
)
admin_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, "admin_token": "founding-admin-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"])
monkeypatch.setenv("TRF_CONTROL_PLANE_SECRET_KEY", "test-secret-key")
monkeypatch.setenv("TRF_SIGNING_KEY_HEX", "11" * 32)
import importlib
from target_revenue.service import control_plane_app as mod
importlib.reload(mod)
if hasattr(mod.app.state, "pool"):
mod.app.state.pool.close()
del mod.app.state.pool
if hasattr(mod.app.state, "signing_key"):
del mod.app.state.signing_key
from fastapi.testclient import TestClient
with TestClient(mod.app) as test_client:
yield test_client
if hasattr(mod.app.state, "pool"):
mod.app.state.pool.close()
@pytest.fixture()
def conn(pg_container):
with psycopg.connect(pg_container["app_dsn"]) as connection:
yield connection
@pytest.fixture()
def credentials(conn, pg_container):
from target_revenue import registry
admin = registry.authenticate(conn, pg_container["admin_token"])
suffix = uuid.uuid4().hex[:8]
tiers = {}
for label, rights in [
("viewer-user", "viewer"),
("contributor-user", "contributor"),
("operator-user", "operator"),
]:
cred = registry.issue_sub_credential(
conn, licensor_id="binky", credential_label=f"{label}-{suffix}", rights=rights,
issued_by="founder",
)
tiers[rights] = cred
conn.commit()
tiers["admin"] = admin
return tiers
def _login(client, token):
resp = client.post("/login", data={"token": token}, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"
def test_root_requires_login_redirects(client):
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
def test_invalid_token_rejected(client):
resp = client.post("/login", data={"token": "not-a-real-token"}, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
def test_valid_login_then_dashboard(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/")
assert resp.status_code == 200
assert "Phases for binky" in resp.text
def test_viewer_cannot_reach_phase_new(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/phases/new", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"
def test_operator_registers_phase_and_appends_entry(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:cpapp-test-" + uuid.uuid4().hex[:8]
resp = client.post(
"/phases/new",
data={
"phase_id": phase_id,
"milestone_release_name": "CP UI smoke test release",
"source_revision": "abc123",
"repo_hub": "forgejo-coulomb",
"repo_hub_uri": "https://forgejo.coulomb.social",
"repo_id": "103",
"repo_name": "coulomb/target-revenue",
"initial_target_amount": "1000",
"currency": "USD",
"future_license": "MIT",
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
"longstop_at": "2027-01-01T00:00:00Z",
},
follow_redirects=False,
)
assert resp.status_code == 303
assert resp.headers["location"] == f"/phases/{phase_id}"
detail = client.get(f"/phases/{phase_id}")
assert detail.status_code == 200
assert phase_id in detail.text
assert f"/phases/{phase_id}/ledger" in detail.text
ledger_resp = client.post(
f"/phases/{phase_id}/ledger",
data={
"entry_id": "trsl:entry:cpappledger0001",
"amount": "500",
"currency": "USD",
"recognized_at": "2026-08-01T00:00:00Z",
"evidence_reference": "confidential:evidence:cpappledger0001",
"extension_id": "trsl:extension:development-license",
"extension_version": "1.0",
},
follow_redirects=False,
)
assert ledger_resp.status_code == 303
detail_after = client.get(f"/phases/{phase_id}")
assert "trsl:entry:cpappledger0001" in detail_after.text
def test_contributor_proposes_operator_approves(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:cpapp-propose-" + uuid.uuid4().hex[:8]
client.post(
"/phases/new",
data={
"phase_id": phase_id,
"milestone_release_name": "CP UI propose-flow release",
"source_revision": "def456",
"repo_hub": "forgejo-coulomb",
"repo_hub_uri": "https://forgejo.coulomb.social",
"repo_id": "103",
"repo_name": "coulomb/target-revenue",
"initial_target_amount": "1000",
"currency": "USD",
"future_license": "MIT",
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
"longstop_at": "2027-01-01T00:00:00Z",
},
)
client.post("/logout")
_login(client, credentials["contributor"].token)
propose_resp = client.post(
f"/phases/{phase_id}/ledger",
data={
"entry_id": "trsl:entry:cpappprop0001",
"amount": "250",
"currency": "USD",
"recognized_at": "2026-08-01T00:00:00Z",
"evidence_reference": "confidential:evidence:cpappprop0001",
"extension_id": "trsl:extension:development-license",
"extension_version": "1.0",
},
follow_redirects=False,
)
assert propose_resp.status_code == 303
client.post("/logout")
_login(client, credentials["operator"].token)
proposals_page = client.get("/proposals")
assert proposals_page.status_code == 200
assert phase_id in proposals_page.text
def test_admin_issues_and_revokes_credential(client, credentials):
_login(client, credentials["admin"].token)
issue_resp = client.post(
"/admin/credentials",
data={"credential_label": f"dana-{uuid.uuid4().hex[:6]}", "rights": "viewer"},
)
assert issue_resp.status_code == 200
assert "New credential issued" in issue_resp.text
def test_non_admin_cannot_reach_admin_credentials(client, credentials):
_login(client, credentials["operator"].token)
resp = client.get("/admin/credentials", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"
def test_audit_log_visible_to_signed_in_user(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/audit")
assert resp.status_code == 200
def test_reference_policy_doc_renders(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/reference/policies/linear-longstop-v0")
assert resp.status_code == 200
assert "Linear Longstop v0" in resp.text
assert "clamp" in resp.text
def test_reference_profile_doc_renders(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/reference/profiles/development-license")
assert resp.status_code == 200
assert "Development License" in resp.text
assert "Commercial Entitlement" in resp.text
def test_reference_unknown_slug_is_404(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/reference/policies/does-not-exist")
assert resp.status_code == 404
def test_reference_unknown_kind_is_404(client, credentials):
_login(client, credentials["viewer"].token)
resp = client.get("/reference/calculators/development-effort-calculator-candidate-a")
assert resp.status_code == 404
def test_reference_requires_login(client):
resp = client.get("/reference/policies/linear-longstop-v0", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
def test_phase_detail_links_to_policy_reference(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:cpapp-refcheck-" + uuid.uuid4().hex[:8]
client.post(
"/phases/new",
data={
"phase_id": phase_id,
"milestone_release_name": "CP UI reference-link check",
"source_revision": "abc123",
"repo_hub": "forgejo-coulomb",
"repo_hub_uri": "https://forgejo.coulomb.social",
"repo_id": "103",
"repo_name": "coulomb/target-revenue",
"initial_target_amount": "1000",
"currency": "USD",
"future_license": "MIT",
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
"longstop_at": "2027-01-01T00:00:00Z",
},
)
detail = client.get(f"/phases/{phase_id}")
assert "/reference/policies/linear-longstop-v0" in detail.text
def test_form_bridge_script_present(client):
"""whynot-design's wn-input/wn-select/wn-button are not
form-associated custom elements — their real <input>/<select>/
<button> live inside shadow DOM, invisible to an ancestor <form>. A
plain click on wn-button[type=submit] silently does nothing, and
even a submitted form would carry none of the field values. This
only regressed once (base.html's bridging script), so pin its
presence — TestClient can't click a real button/shadow DOM, this is
the closest offline check available without a browser-automation
tool."""
resp = client.get("/login")
assert "wn-button[type=submit]" in resp.text
assert "requestSubmit" in resp.text
assert "data-wn-mirror-for" in resp.text
def test_phase_new_form_has_no_ledger_input(client, credentials):
"""WP-0012-T04: the ledger reference is auto-computed, never
hand-typed. Regression test for the registration form itself, not
just the route's behavior."""
_login(client, credentials["operator"].token)
resp = client.get("/phases/new")
assert 'name="ledger"' not in resp.text
assert 'name="repo_hub"' in resp.text
assert 'name="repo_hub_uri"' in resp.text
assert 'name="repo_id"' in resp.text
assert 'name="repo_name"' in resp.text
# --- WP-0014: extensions / breach / attestation UI -------------------------
def _register_phase(client, phase_id, amount="1000"):
return client.post(
"/phases/new",
data={
"phase_id": phase_id,
"milestone_release_name": "WP-0014 test release",
"source_revision": "abc123",
"repo_hub": "forgejo-coulomb",
"repo_hub_uri": "https://forgejo.coulomb.social",
"repo_id": "103",
"repo_name": "coulomb/target-revenue",
"initial_target_amount": amount,
"currency": "USD",
"future_license": "MIT",
"degeneration_policy": "trsl:policy:linear-longstop-v0@1.0",
"longstop_at": "2027-01-01T00:00:00Z",
},
follow_redirects=False,
)
def test_viewer_can_list_extensions_operator_registers_admin_promotes(
client, credentials
):
ext_id = "trsl:extension:wp0014-test-" + uuid.uuid4().hex[:6]
version = "1.0"
_login(client, credentials["viewer"].token)
resp = client.get("/extensions")
assert resp.status_code == 200
assert "Monetization Extension Registry" in resp.text
# Viewer cannot register
denied = client.post(
"/extensions",
data={
"extension_id": ext_id,
"version": version,
"value_description": "Test extension for WP-0014",
"pricing_method": "fixed-fee",
"allocation_rule": "100% to Development Credit",
"default_rate": "1.0",
"recognition_event": "payment-settled",
"reversal_rule": "Refunds reverse the credit",
"evidence_requirement": "Settled payment reference",
},
follow_redirects=False,
)
assert denied.status_code == 303
_login(client, credentials["operator"].token)
reg = client.post(
"/extensions",
data={
"extension_id": ext_id,
"version": version,
"value_description": "Test extension for WP-0014",
"pricing_method": "fixed-fee",
"allocation_rule": "100% to Development Credit",
"default_rate": "1.0",
"recognition_event": "payment-settled",
"reversal_rule": "Refunds reverse the credit",
"evidence_requirement": "Settled payment reference",
},
follow_redirects=False,
)
assert reg.status_code == 303
assert reg.headers["location"] == "/extensions"
listed = client.get("/extensions")
assert ext_id in listed.text
assert "registered" in listed.text
# Operator cannot promote
op_promote = client.post(
"/extensions/promote",
data={"extension_id": ext_id, "version": version},
follow_redirects=False,
)
assert op_promote.status_code == 303
_login(client, credentials["admin"].token)
promote = client.post(
"/extensions/promote",
data={"extension_id": ext_id, "version": version},
follow_redirects=False,
)
assert promote.status_code == 303
after = client.get("/extensions")
assert "canonical" in after.text
def test_operator_publishes_anonymized_breach(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:wp0014-breach-" + uuid.uuid4().hex[:8]
assert _register_phase(client, phase_id).status_code == 303
record_id = "trsl:breach:wp0014-" + uuid.uuid4().hex[:8]
resp = client.post(
f"/phases/{phase_id}/breach",
data={
"record_id": record_id,
"case_id": "case-001",
"event_type": "alleged",
"category": "unauthorized-commercial-use",
"event_at": "2026-08-01T12:00:00Z",
"evidence_reference": "confidential:evidence:case-001",
"anonymized": "true",
"named_entitlement_holder": "",
"named_disclosure_authorized": "false",
},
follow_redirects=False,
)
assert resp.status_code == 303, resp.headers
assert resp.headers["location"] == f"/phases/{phase_id}"
detail = client.get(f"/phases/{phase_id}")
assert detail.status_code == 200
assert record_id in detail.text
assert "unauthorized-commercial-use" in detail.text
assert "anonymized" in detail.text
def test_named_breach_requires_cua_authorization(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:wp0014-named-" + uuid.uuid4().hex[:8]
assert _register_phase(client, phase_id).status_code == 303
# Missing CUA authorization → rejected (flash danger, still redirect)
resp = client.post(
f"/phases/{phase_id}/breach",
data={
"record_id": "trsl:breach:named-fail-" + uuid.uuid4().hex[:6],
"case_id": "case-named",
"event_type": "determined",
"category": "payment-default",
"event_at": "2026-08-02T00:00:00Z",
"anonymized": "false",
"named_entitlement_holder": "Acme Corp",
"named_disclosure_authorized": "false",
},
follow_redirects=True,
)
assert resp.status_code == 200
# Should not show a published named record
assert "Acme Corp" not in resp.text or "named_disclosure_authorized" in resp.text.lower() or "rejected" in resp.text.lower() or "must be true" in resp.text.lower() or "danger" in resp.text.lower() or "flash" in resp.text.lower()
# With authorization → accepted
ok = client.post(
f"/phases/{phase_id}/breach",
data={
"record_id": "trsl:breach:named-ok-" + uuid.uuid4().hex[:6],
"case_id": "case-named",
"event_type": "determined",
"category": "payment-default",
"event_at": "2026-08-02T00:00:00Z",
"anonymized": "false",
"named_entitlement_holder": "Acme Corp",
"named_disclosure_authorized": "true",
},
follow_redirects=False,
)
assert ok.status_code == 303
detail = client.get(f"/phases/{phase_id}")
assert "Acme Corp" in detail.text
def test_attestation_shown_after_conversion(client, credentials):
_login(client, credentials["operator"].token)
phase_id = "trsl:phase:wp0014-attest-" + uuid.uuid4().hex[:8]
assert _register_phase(client, phase_id, amount="100").status_code == 303
before = client.get(f"/phases/{phase_id}")
assert "Not converted" in before.text or "no attestation" in before.text.lower()
client.post(
f"/phases/{phase_id}/ledger",
data={
"entry_id": "trsl:entry:wp0014full" + uuid.uuid4().hex[:6],
"amount": "100",
"currency": "USD",
"recognized_at": "2026-08-01T00:00:00Z",
"evidence_reference": "confidential:evidence:full",
"extension_id": "trsl:extension:development-license",
"extension_version": "1.0",
},
follow_redirects=False,
)
after = client.get(f"/phases/{phase_id}")
assert after.status_code == 200
assert "Conversion Attestation" in after.text
assert "MIT" in after.text # future_license
assert "ledger_checkpoint" in after.text or "Ledger checkpoint" in after.text