Files
rfc-app/docs/design/2026-06-07-deployed-env-e2e-harness.md
Ben Stull 83eafe72ee feat(e2e): deployed-environment E2E harness + gated test-auth (v0.52.0)
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>
2026-06-07 23:35:53 -07:00

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:

  1. A faceted bdd collection, seeded into a throwaway Gitea by testing/seed-gitea.sh.
  2. A granted-owner identity (e2e-owner@example.test), injected directly into SQLite by the docker-compose backend-seed step.
  3. 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 404 unless both E2E_TEST_AUTH_SECRET and E2E_TEST_AUTH_EMAIL are set. A production deployment sets neither, so the route is invisible and inert.
  • Secret-gated. The caller must present E2E_TEST_AUTH_SECRET in the X-Test-Auth-Secret header, compared in constant time. A wrong/absent secret returns 404 (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 is 403. 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 WARNING at 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 keeps rfc-registry, so prod is never affected.
  • wiggleverse/rfc-app-ppe-content — one project ohm (document) with a default collection entry plus a faceted bdd named collection (priority enum + tags) and three entries, mirroring the Tier-1 seed. The E2E path /p/ohm/c/bdd therefore 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.js already honors BASE_URL (default http://localhost:8080); PPE sets BASE_URL=https://rfc-ppe.wiggleverse.org.
  • e2e/lib/auth.js branches on E2E_TEST_AUTH_SECRET: set → use /auth/test/login; unset → the original Mailpit OTC path. OWNER_EMAIL reads E2E_OWNER_EMAIL (PPE points it at E2E_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's make e2e-fresh, PPE has no per-run teardown.
  • smoke.spec.js stays Tier-1-only (anonymous OTC smoke through Mailpit); only metadata.spec.js runs against PPE.