The §9 pipeline's PPE+E2E stage was unreachable: e2e/metadata.spec.js was bound to Tier-1-only scaffolding (docker-seeded faceted collection, SQLite-injected owner, Mailpit OTC sink). This makes the same suite run against a deployed host. - backend: POST /auth/test/login — fail-closed, secret-gated, single- identity owner test-login (404 unless E2E_TEST_AUTH_SECRET + E2E_TEST_AUTH_EMAIL both set; constant-time compare; loud startup warn). 6 vertical tests; backend 665 green. Documented in backend/.env.example. - e2e/lib/auth.js: branch on E2E_TEST_AUTH_SECRET (deployed test-login vs local Mailpit OTC); OWNER_EMAIL from E2E_OWNER_EMAIL. Spec unchanged so the localhost Tier-1 path keeps working. - testing/seed-ppe.sh: seed a dedicated, prod-untouching PPE registry + content repo (faceted bdd collection) — real OHM content never touched. - docs/design/2026-06-07-deployed-env-e2e-harness.md; CHANGELOG; VERSION + frontend/package.json -> 0.52.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5.7 KiB
Deployed-environment E2E harness (PPE)
Date: 2026-06-07 · Version: v0.52.0 · Status: implemented
Why
The §9 deployment pipeline is localhost + E2E → PPE + E2E → prod. The
middle stage — running the Playwright E2E suite against a deployed
pre-prod host (https://rfc-ppe.wiggleverse.org) — was unreachable
because the suite (e2e/metadata.spec.js, SLICE-3/4/5 of the
configurable-collection-metadata work) was bound to three scaffolds that
exist only in the local Tier-1 docker stack:
- A faceted
bddcollection, seeded into a throwaway Gitea bytesting/seed-gitea.sh. - A granted-owner identity (
e2e-owner@example.test), injected directly into SQLite by the docker-composebackend-seedstep. - Mailpit, the SMTP sink the OTC sign-in reads the one-time code from.
PPE has none of these: it runs against the real git.wiggleverse.org
(shared with prod), has no direct DB access, and has no mail sink. This
note records how each coupling is replaced so the same spec runs green
against both localhost and PPE.
The three seams
1. Auth — a gated test-login endpoint (the framework change)
A new backend route, POST /auth/test/login, replaces both the Mailpit
OTC dance and the SQLite owner injection with one gesture: it mints an
authenticated owner session for a single pre-configured identity.
It is the framework's only auth bypass, so it is fail-closed and must never function in production:
- Off by default. It returns
404unless bothE2E_TEST_AUTH_SECRETandE2E_TEST_AUTH_EMAILare set. A production deployment sets neither, so the route is invisible and inert. - Secret-gated. The caller must present
E2E_TEST_AUTH_SECRETin theX-Test-Auth-Secretheader, compared in constant time. A wrong/absent secret returns404(it does not advertise the route's existence). - Single identity. It will only mint the one configured
E2E_TEST_AUTH_EMAIL(case-insensitive); any other address is403. So an enabled PPE exposes exactly one throwaway owner, with the secret as the trust boundary. - Loud at startup. When enabled, the app logs a
WARNINGat boot, so an accidental prod enablement is visible rather than silent.
On success it provision-or-links the row (reusing otc.provision_or_link_user),
forces it to role='owner', permission_state='granted' (the deployed
equivalent of the Tier-1 owner-seed), and stores the session exactly like
the OTC verify path.
Why an endpoint rather than alternatives. Reading the OTC code from
the VM's journald (the email adapter logs the envelope to stdout when
SMTP is unconfigured) would couple the test harness to gcloud SSH at
runtime — slow, brittle, and operator-cred-bound. Running Mailpit on the
VM and exposing its API publicly is more infra and its own exposure
surface. A default-off, secret-gated endpoint is the portable engineering
seam: it works for any deployed environment, needs no SSH, and the
secrets rule (§6.3) is honored — the secret is a Secret Manager ref
injected as VM env, never a literal.
The hard-secrets caveat: the E2E runner presents the secret by resolving it from Secret Manager at runtime (command substitution), never echoing it.
2. Content — a dedicated PPE registry + content repo
PPE shares the prod Gitea org (wiggleverse) and, until now, prod's
registry (rfc-registry) and default project (ohm). Seeding a faceted
test collection into that shared registry would surface it on prod.
So PPE gets its own, prod-untouching fixtures:
wiggleverse/rfc-registry-ppe— PPE's project registry. Prod keepsrfc-registry, so prod is never affected.wiggleverse/rfc-app-ppe-content— one projectohm(document) with a default collection entry plus a facetedbddnamed collection (priorityenum +tags) and three entries, mirroring the Tier-1 seed. The E2E path/p/ohm/c/bddtherefore resolves identically on both environments.
PPE is pointed at it with overlay set rfc-app-ppe REGISTRY_REPO=rfc-registry-ppe. The startup reconciler sweep loads the
content into cached_rfcs (incl. meta_json for facets) on the next
deploy — no webhook needed for the initial load. The seed is scripted in
testing/seed-ppe.sh (idempotent; RESEED=1 restores entry values for a
re-run). Repo creation is a one-time operator gesture (the
write:repository Keychain token cannot create org repos; create the two
empty repos in the Gitea UI or re-scope the PAT).
3. Parameterization — one spec, two environments
e2e/playwright.config.jsalready honorsBASE_URL(defaulthttp://localhost:8080); PPE setsBASE_URL=https://rfc-ppe.wiggleverse.org.e2e/lib/auth.jsbranches onE2E_TEST_AUTH_SECRET: set → use/auth/test/login; unset → the original Mailpit OTC path.OWNER_EMAILreadsE2E_OWNER_EMAIL(PPE points it atE2E_TEST_AUTH_EMAIL) or the Tier-1 default. The spec itself is unchanged, so the localhost Tier-1 path keeps working.
PPE version
The harness requires the test-login endpoint to exist in the deployed
build, so PPE must run a framework version that contains it — v0.52.0,
not v0.51.1. PPE is pinned ahead of prod via its own
ben/ohm-rfc/.rfc-app-version.ppe (prod stays on .rfc-app-version),
realizing the "PPE stages newer versions first" note the
deployment.ppe.toml always anticipated.
Known limitations
- Re-runnability. SLICE-4/5 mutate the seeded entries (commit
sidecars). A clean run needs seed-state preconditions; re-run after
RESEED=1+ a cache refresh (next reconciler sweep or a redeploy). Unlike Tier-1'smake e2e-fresh, PPE has no per-run teardown. - smoke.spec.js stays Tier-1-only (anonymous OTC smoke through
Mailpit); only
metadata.spec.jsruns against PPE.