New service/reference_docs.py renders specs/policies/*.md and
specs/profiles/*.md read-only at request time via a small markdown
library (added markdown + PyYAML to the service extras) -- not a
static-build pipeline, matching the WP-0012-T03 decision to skip
state-hub's heavier Observable Framework pattern.
One parameterized route, GET /reference/{kind}/{slug}, covers both
addendum URL shapes. Discovered phase_detail.html's Status table never
displayed the degeneration_policy id at all -- added that row (with
the reference link) rather than wiring a link with nothing to attach
it to. phase_new.html gets a plain link next to the field.
Deliberately did not wire extension-id links into the UI in this task
-- extension ids don't appear anywhere in the Control Plane today
(that's WP-0014's gap, not this one's to expand).
6 new Docker-gated tests. Full suite: 94 passing offline, 164 passing
with Docker (up from 158).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
383 lines
13 KiB
Python
383 lines
13 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
|