Define and implement multi-repo onboarding mechanism (WP-0006-T07)
specs/TrustServiceOnboarding.md defines the mechanism: a Phase Manifest file is committed to the declaring repo (durable, independently foldable forever) and separately registered with the hosted service; once registered, the Ledger's live authoritative copy is the hosted service only, not a second competing file. Licensor token bootstrapping is explicitly out of scope here (a WP-0008-T01 governance action). scripts/trf_onboard.py: a dependency-light CLI (stdlib urllib + target_revenue.validation only, no FastAPI/psycopg needed to onboard a Phase) with validate/register-phase/append-entry/status subcommands. The Licensor token is read only from a named environment variable, never accepted as a literal argument. tests/test_trf_onboard.py (4 tests, no network/Docker) proves invalid-manifest and missing-token-env cases fail before any HTTP attempt, by monkeypatching the request function to raise if called. tests/test_onboarding_hosted.py (1 Docker-gated test) runs an actual uvicorn server on a real socket and drives the full register -> append -> status round trip through the CLI as an external repo would invoke it.
This commit is contained in:
parent
45b9fbd765
commit
ee4cf14cbc
6 changed files with 530 additions and 2 deletions
|
|
@ -78,7 +78,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule**
|
||||||
| [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 |
|
| [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 |
|
||||||
| [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) |
|
| [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) |
|
||||||
| [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) |
|
| [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) |
|
||||||
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (registries), T04 (Ledger append API), T05 (Metrics), T06 (Conversion Attestation) done; T07 (multi-repo onboarding) next |
|
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (registries), T04 (Ledger append API), T05 (Metrics), T06 (Conversion Attestation), T07 (onboarding mechanism, `specs/TrustServiceOnboarding.md` + `scripts/trf_onboard.py`) done; T08 (hosted conformance suite) next |
|
||||||
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started |
|
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started |
|
||||||
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 |
|
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 |
|
||||||
|
|
||||||
|
|
|
||||||
136
scripts/trf_onboard.py
Normal file
136
scripts/trf_onboard.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Trust Service onboarding CLI (WP-0006-T07).
|
||||||
|
|
||||||
|
Mechanism only — see `specs/TrustServiceOnboarding.md` for the full
|
||||||
|
process this wraps. Deliberately dependency-light: stdlib `urllib` for
|
||||||
|
HTTP (no `requests`/`httpx` requirement) plus `target_revenue.validation`
|
||||||
|
(already a core, non-service dependency) for offline pre-flight checks —
|
||||||
|
a repo onboarding a Phase should not need to install FastAPI/psycopg just
|
||||||
|
to register one.
|
||||||
|
|
||||||
|
Every subcommand that submits data validates it offline first, with the
|
||||||
|
same check the hosted service itself runs at registration
|
||||||
|
(`validation.py`), so a rejection is caught locally with an identical
|
||||||
|
field-by-field diff instead of only being discovered via a failed network
|
||||||
|
call (specs/TrustServiceOnboarding.md §3 steps 2 and 4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
|
|
||||||
|
from target_revenue import validation # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: str) -> dict:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(url: str, method: str, token: str | None = None, body: dict | None = None) -> dict:
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8")
|
||||||
|
raise SystemExit(f"{method} {url} -> HTTP {exc.code}: {detail}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _token_from_env(env_var: str) -> str:
|
||||||
|
token = os.environ.get(env_var)
|
||||||
|
if not token:
|
||||||
|
raise SystemExit(
|
||||||
|
f"environment variable {env_var!r} is not set — the Licensor "
|
||||||
|
"API token must never be passed on the command line or "
|
||||||
|
"committed to the repo (specs/TrustServiceOnboarding.md §2)"
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(args: argparse.Namespace) -> None:
|
||||||
|
manifest = _load(args.manifest)
|
||||||
|
try:
|
||||||
|
validation.validate_phase_manifest(manifest)
|
||||||
|
except validation.ConformanceError as exc:
|
||||||
|
print("REJECTED:", "; ".join(exc.errors), file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"OK: {manifest['phase']['id']} is schema-conformant")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_register_phase(args: argparse.Namespace) -> None:
|
||||||
|
manifest = _load(args.manifest)
|
||||||
|
try:
|
||||||
|
validation.validate_phase_manifest(manifest)
|
||||||
|
except validation.ConformanceError as exc:
|
||||||
|
print("REJECTED (offline check, no network call made):", "; ".join(exc.errors), file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
token = _token_from_env(args.token_env)
|
||||||
|
result = _request(f"{args.url}/phases", "POST", token=token, body=manifest)
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_append_entry(args: argparse.Namespace) -> None:
|
||||||
|
entry = _load(args.entry)
|
||||||
|
token = _token_from_env(args.token_env)
|
||||||
|
result = _request(
|
||||||
|
f"{args.url}/phases/{args.phase_id}/ledger", "POST", token=token, body=entry
|
||||||
|
)
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args: argparse.Namespace) -> None:
|
||||||
|
result = _request(f"{args.url}/phases/{args.phase_id}/metrics", "GET")
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="trf_onboard", description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_validate = sub.add_parser("validate", help="offline-validate a Phase Manifest file, no network call")
|
||||||
|
p_validate.add_argument("manifest")
|
||||||
|
p_validate.set_defaults(func=cmd_validate)
|
||||||
|
|
||||||
|
p_register = sub.add_parser("register-phase", help="validate offline, then POST /phases")
|
||||||
|
p_register.add_argument("--url", required=True, help="Trust Service base URL")
|
||||||
|
p_register.add_argument("--token-env", required=True, help="env var holding the Licensor API token")
|
||||||
|
p_register.add_argument("--manifest", required=True)
|
||||||
|
p_register.set_defaults(func=cmd_register_phase)
|
||||||
|
|
||||||
|
p_append = sub.add_parser("append-entry", help="POST a Ledger entry for an already-registered Phase")
|
||||||
|
p_append.add_argument("--url", required=True)
|
||||||
|
p_append.add_argument("--token-env", required=True)
|
||||||
|
p_append.add_argument("--phase-id", required=True)
|
||||||
|
p_append.add_argument("--entry", required=True)
|
||||||
|
p_append.set_defaults(func=cmd_append_entry)
|
||||||
|
|
||||||
|
p_status = sub.add_parser("status", help="GET /phases/{id}/metrics (public, no token)")
|
||||||
|
p_status.add_argument("--url", required=True)
|
||||||
|
p_status.add_argument("--phase-id", required=True)
|
||||||
|
p_status.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
106
specs/TrustServiceOnboarding.md
Normal file
106
specs/TrustServiceOnboarding.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Trust Service Onboarding: Multi-Repo Registration Mechanism
|
||||||
|
|
||||||
|
Status: Draft v0.1
|
||||||
|
Date: 2026-07-29
|
||||||
|
Workplan: `workplans/TREV-WP-0006-trust-service-implementation.md` T07
|
||||||
|
Primary artifacts: `specs/PhaseManifestSpecification.md`, `specs/TargetLedgerSpecification.md`, `specs/TrustServiceProductRequirementsDocument.md`, `docs/adr/ADR-0002-hosted-trust-service-stack.md`, `scripts/trf_onboard.py`
|
||||||
|
|
||||||
|
**Scope note:** this document defines the **mechanism** by which any repo
|
||||||
|
registers a Phase against a running hosted Trust Service instance. It does
|
||||||
|
**not** select which repos or Phases go first — that is
|
||||||
|
`workplans/TREV-WP-0008-governance-and-pilot-rollout.md` T02 (repo/Phase
|
||||||
|
survey) and T03 (draft pilot manifests), which use this mechanism rather
|
||||||
|
than redefine it. It also does not authorize any real Phase to go live —
|
||||||
|
that remains gated behind WP-0008-T05 regardless of how well this
|
||||||
|
mechanism works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What a repo commits to itself vs. submits to the Trust Service
|
||||||
|
|
||||||
|
A recurring confusion this document exists to close: a Phase Manifest is
|
||||||
|
**not** solely a Trust Service database row. It has two authoritative
|
||||||
|
copies with different roles, and both are required, not just one:
|
||||||
|
|
||||||
|
| Artifact | Lives in | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase Manifest file (e.g. `trf/phase-<slug>.json`) | The declaring repo's own version control, committed | The repo's own durable, git-history-backed record of what it declared and when — survives independent of any Trust Service instance's uptime or even existence (TSD §1.2's offline-verifiability property applies here too: this file alone, plus its ledger export, must remain independently foldable forever). |
|
||||||
|
| The same Phase Manifest, registered | The hosted Trust Service's `phase_manifests` table (`migrations/0001_registries.sql`) | The multi-tenant, queryable, publicly-servable copy other parties (Customers, auditors, Enforcement Partners) read without needing access to the declaring repo at all. |
|
||||||
|
| Target Ledger entries | The hosted Trust Service's `ledger_entries` table only | Per ADR-0002, the Ledger's authoritative live copy is the hosted service once a Phase is registered there — a repo does **not** also maintain a second, competing ledger file post-registration (that would reintroduce exactly the "which copy is authoritative" ambiguity Stage 0's single-golden-fixture model never had to answer). A repo *may* keep periodic exports for its own archival/offline-verification purposes (§4 below), but those are copies, not a second source of truth. |
|
||||||
|
| Extension registrations, Conversion Attestation | The hosted Trust Service, per T03/T06 | Same reasoning as the Ledger. |
|
||||||
|
|
||||||
|
**Rule of thumb:** anything that must exist and remain meaningful even if
|
||||||
|
this Trust Service instance is retired, replaced, or briefly down belongs
|
||||||
|
committed to the repo (the Manifest declaration itself). Anything that
|
||||||
|
must be a single, live, multi-party-queryable authority belongs
|
||||||
|
exclusively in the hosted service once registered (the Ledger).
|
||||||
|
|
||||||
|
## 2. Licensor bootstrapping (out of band, not part of this mechanism)
|
||||||
|
|
||||||
|
Before any repo can register a Phase, its Licensor identity must already
|
||||||
|
be resolved (`workplans/TREV-WP-0008-governance-and-pilot-rollout.md` T01)
|
||||||
|
and a row must exist in the `licensors` table with an issued API token
|
||||||
|
(`migrations/0001_registries.sql`). This document assumes that step has
|
||||||
|
already happened; it is a governance action, not a mechanical one, and is
|
||||||
|
explicitly out of this task's scope. The token itself must never be
|
||||||
|
committed to the repo — it is a deployment secret (CI secret store,
|
||||||
|
environment variable at declaration time), referenced by name in the
|
||||||
|
repo's own tooling, never by value.
|
||||||
|
|
||||||
|
## 3. Step-by-step mechanism
|
||||||
|
|
||||||
|
1. **Author the Phase Manifest locally**, conforming to
|
||||||
|
`specs/PhaseManifestSpecification.md` and
|
||||||
|
`schemas/phase_manifest.schema.json`, following the shape already
|
||||||
|
established by `examples/phase-001/manifest.json`.
|
||||||
|
2. **Validate offline before doing anything else** —
|
||||||
|
`python -m target_revenue.validation` equivalent via
|
||||||
|
`scripts/trf_onboard.py validate <manifest-path>` (wraps
|
||||||
|
`validation.validate_phase_manifest`, the same check the hosted
|
||||||
|
service itself runs at registration, so a rejection is caught locally
|
||||||
|
with the identical field-by-field diff rather than discovered only via
|
||||||
|
a failed network call).
|
||||||
|
3. **Commit the Manifest file to the repo** before registering it — this
|
||||||
|
preserves a git-history record of intent to declare, independent of
|
||||||
|
whether registration succeeds on the first attempt.
|
||||||
|
4. **Register**: `scripts/trf_onboard.py register-phase --url <trust-service-url> --token-env <ENV_VAR_NAME> --manifest <path>`. Performs the same offline validation as step 2 again (defense in depth — a manifest edited after step 2 but before this step must still be caught), then `POST /phases`.
|
||||||
|
5. **Append Ledger entries as commercial payments are recognized.** In
|
||||||
|
practice this step is automated by whatever billing/payment pipeline
|
||||||
|
the product line already uses, not run manually per payment —
|
||||||
|
`scripts/trf_onboard.py append-entry --url ... --token-env ... --phase-id ... --entry <path>` is the mechanism that pipeline calls, or a template for it.
|
||||||
|
6. **Monitor via the public, unauthenticated read endpoints** —
|
||||||
|
`scripts/trf_onboard.py status --url ... --phase-id ...` (wraps
|
||||||
|
`GET /phases/{id}/metrics`) — no token needed for this step, since
|
||||||
|
metrics are public per FR-9/FR-10.
|
||||||
|
7. **Conversion and Attestation require no onboarding action at all.**
|
||||||
|
Per WP-0006-T06, the moment the ledger fold reaches
|
||||||
|
`Outstanding Target = 0`, conversion is already true; the Trust Service
|
||||||
|
publishes the Attestation on first observation. Nothing in this
|
||||||
|
mechanism gates on, waits for, or triggers that separately.
|
||||||
|
|
||||||
|
## 4. Offline verifiability remains intact after hosting
|
||||||
|
|
||||||
|
Any party — including the declaring repo itself, independent of the
|
||||||
|
Trust Service's continued availability — can export a Phase's current
|
||||||
|
Ledger via `GET /phases/{id}/ledger` (public) and independently recompute
|
||||||
|
Outstanding Target with `target_revenue.fold.fold_outstanding_target`
|
||||||
|
against the committed Manifest file. This is the same offline-first
|
||||||
|
property `docs/adr/ADR-0002-hosted-trust-service-stack.md` and
|
||||||
|
`specs/TrustServiceProductRequirementsDocument.md` §3 require the hosted
|
||||||
|
service to preserve, not a new guarantee invented for onboarding — this
|
||||||
|
section just makes explicit that a repo can exercise it as part of its own
|
||||||
|
routine (e.g., a periodic CI job that exports and re-verifies), not only a
|
||||||
|
theoretical possibility.
|
||||||
|
|
||||||
|
## 5. Explicit non-goals
|
||||||
|
|
||||||
|
- Selecting which of `coulomb-loop`, `net-kingdom`, `helix-forge`, or a
|
||||||
|
`railiance-*` repo registers first, or which Milestone Release qualifies
|
||||||
|
— `workplans/TREV-WP-0008-governance-and-pilot-rollout.md` T02/T03.
|
||||||
|
- Resolving Licensor identity — WP-0008-T01.
|
||||||
|
- Authorizing any registration performed via this mechanism to bind real
|
||||||
|
money or a real License header change — WP-0008-T05, unaffected by this
|
||||||
|
mechanism existing or working correctly.
|
||||||
|
- A CI/CD template for automated payment-pipeline integration — a
|
||||||
|
reasonable future deliverable once a real pilot Phase's billing
|
||||||
|
pipeline is known, not invented speculatively here.
|
||||||
173
tests/test_onboarding_hosted.py
Normal file
173
tests/test_onboarding_hosted.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""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",
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
87
tests/test_trf_onboard.py
Normal file
87
tests/test_trf_onboard.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Pure, offline tests for scripts/trf_onboard.py (WP-0006-T07).
|
||||||
|
|
||||||
|
No network call is made in these tests — they verify the offline
|
||||||
|
pre-flight validation short-circuits before any HTTP attempt, and that a
|
||||||
|
missing token env var fails loudly rather than silently sending an
|
||||||
|
unauthenticated request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import golden_manifest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_golden_manifest(tmp_path, capsys):
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(golden_manifest()), encoding="utf-8")
|
||||||
|
|
||||||
|
trf_onboard.main(["validate", str(manifest_path)])
|
||||||
|
|
||||||
|
assert "OK" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_broken_manifest(tmp_path):
|
||||||
|
broken = golden_manifest()
|
||||||
|
del broken["phase"]["initial_target"]
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(broken), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
trf_onboard.main(["validate", str(manifest_path)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_phase_rejects_offline_before_any_network_call(tmp_path, monkeypatch):
|
||||||
|
"""A non-conformant manifest must be caught by the offline check —
|
||||||
|
_request must never be called at all."""
|
||||||
|
broken = golden_manifest()
|
||||||
|
del broken["phase"]["initial_target"]
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(broken), encoding="utf-8")
|
||||||
|
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise AssertionError("no network call should have been made")
|
||||||
|
|
||||||
|
monkeypatch.setattr(trf_onboard, "_request", _boom)
|
||||||
|
monkeypatch.setenv("TRF_TEST_TOKEN", "irrelevant")
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
trf_onboard.main(
|
||||||
|
[
|
||||||
|
"register-phase",
|
||||||
|
"--url", "http://example.invalid",
|
||||||
|
"--token-env", "TRF_TEST_TOKEN",
|
||||||
|
"--manifest", str(manifest_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_phase_requires_token_env_to_be_set(tmp_path, monkeypatch):
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(golden_manifest()), encoding="utf-8")
|
||||||
|
monkeypatch.delenv("TRF_TEST_TOKEN_UNSET", raising=False)
|
||||||
|
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise AssertionError("no network call should have been made without a token")
|
||||||
|
|
||||||
|
monkeypatch.setattr(trf_onboard, "_request", _boom)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match="TRF_TEST_TOKEN_UNSET"):
|
||||||
|
trf_onboard.main(
|
||||||
|
[
|
||||||
|
"register-phase",
|
||||||
|
"--url", "http://example.invalid",
|
||||||
|
"--token-env", "TRF_TEST_TOKEN_UNSET",
|
||||||
|
"--manifest", str(manifest_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -273,7 +273,7 @@ passing under the Docker-gated suite; no stray containers left running.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0006-T07
|
id: TREV-WP-0006-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "44c5846e-1f37-41fe-8ac0-fc7d88628e9f"
|
state_hub_task_id: "44c5846e-1f37-41fe-8ac0-fc7d88628e9f"
|
||||||
```
|
```
|
||||||
|
|
@ -287,6 +287,32 @@ Trust Service (the registration call). Coordinate with
|
||||||
the actual first repos — this task defines the mechanism, not the pilot
|
the actual first repos — this task defines the mechanism, not the pilot
|
||||||
selection.
|
selection.
|
||||||
|
|
||||||
|
**Result:** `specs/TrustServiceOnboarding.md` defines the mechanism: a
|
||||||
|
Phase Manifest file is committed to the declaring repo (durable,
|
||||||
|
git-history-backed, independently foldable forever) *and* registered with
|
||||||
|
the hosted service (the multi-tenant, publicly-servable copy); once
|
||||||
|
registered, the Ledger's live authoritative copy is the hosted service
|
||||||
|
only, not a second competing file — a repo may keep periodic exports for
|
||||||
|
its own offline re-verification, but those are copies, not a source of
|
||||||
|
truth. Licensor token bootstrapping is explicitly out of this mechanism's
|
||||||
|
scope (a WP-0008-T01 governance action). `scripts/trf_onboard.py`
|
||||||
|
implements it as a dependency-light CLI (stdlib `urllib` + the existing
|
||||||
|
`target_revenue.validation`, no FastAPI/psycopg required to onboard a
|
||||||
|
Phase) with four subcommands: `validate` (offline only, no network),
|
||||||
|
`register-phase` (validates offline before ever attempting the network
|
||||||
|
call), `append-entry`, and `status` (public, no token). The Licensor API
|
||||||
|
token is read only from an environment variable named on the command
|
||||||
|
line, never accepted as a literal argument or committed. `tests/test_trf_onboard.py`
|
||||||
|
(4 tests, no network, no Docker) proves both invalid-manifest and
|
||||||
|
missing-token-env cases fail before any HTTP attempt is made — asserted by
|
||||||
|
monkeypatching the request function to raise if called at all.
|
||||||
|
`tests/test_onboarding_hosted.py` (1 Docker-gated test) is the one test in
|
||||||
|
the suite that runs an actual `uvicorn` server on a real socket (rather
|
||||||
|
than FastAPI's in-process TestClient, since the CLI genuinely speaks HTTP)
|
||||||
|
and drives the full register → append → status round trip through the CLI
|
||||||
|
exactly as an external repo would invoke it. Full suite: 49 passing
|
||||||
|
offline, 71 passing with Docker; no stray containers left running.
|
||||||
|
|
||||||
## Conformance test suite at hosted scale
|
## Conformance test suite at hosted scale
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue