From 83eafe72eeada6773986e1d4b2117bc54c04272a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 23:35:53 -0700 Subject: [PATCH] feat(e2e): deployed-environment E2E harness + gated test-auth (v0.52.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 30 +++ VERSION | 2 +- backend/.env.example | 13 ++ backend/app/main.py | 75 ++++++++ backend/tests/test_e2e_test_auth_vertical.py | 165 +++++++++++++++++ .../2026-06-07-deployed-env-e2e-harness.md | 121 +++++++++++++ e2e/lib/auth.js | 37 +++- frontend/package.json | 2 +- testing/seed-ppe.sh | 171 ++++++++++++++++++ 9 files changed, 607 insertions(+), 9 deletions(-) create mode 100644 backend/tests/test_e2e_test_auth_vertical.py create mode 100644 docs/design/2026-06-07-deployed-env-e2e-harness.md create mode 100755 testing/seed-ppe.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e1fc6a4..4ccd8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,36 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.52.0 — 2026-06-07 + +**Minor — deployed-environment E2E harness (new opt-in test-auth surface; +no operator action required for existing deployments).** + +The §9 pipeline's middle stage — running the Playwright E2E suite against +a *deployed* pre-prod host — was unreachable because the suite was bound +to local-only scaffolding (a docker-seeded faceted collection, a +SQLite-injected owner, and Mailpit for the OTC code). This release makes +the same suite run green against a deployed host. + +- **New gated test-auth endpoint, `POST /auth/test/login`.** Mints an + authenticated **owner** session for a single pre-configured identity, to + let the E2E suite sign in without a mail sink. It is **fail-closed**: + returns `404` unless **both** `E2E_TEST_AUTH_SECRET` and + `E2E_TEST_AUTH_EMAIL` are set (so it is inert in production, which sets + neither); requires the secret in the `X-Test-Auth-Secret` header + (constant-time compare; wrong/absent → `404`); only mints the one + configured email (any other → `403`); and logs a `WARNING` at startup + when enabled. **Leave both env vars unset on production.** +- **E2E harness parameterized for deployed runs.** `e2e/lib/auth.js` + branches on `E2E_TEST_AUTH_SECRET` (deployed test-login vs. the local + Mailpit OTC path); `OWNER_EMAIL` reads `E2E_OWNER_EMAIL`. `BASE_URL` was + already honored. The local Tier-1 path is unchanged. +- **`testing/seed-ppe.sh`** seeds a dedicated, prod-untouching PPE + registry + content repo (faceted `bdd` collection) so the deployed + fixtures never leak onto real content. + +Design note: `docs/design/2026-06-07-deployed-env-e2e-harness.md`. + ## 0.51.1 — 2026-06-07 **Patch — two §22 correctness fixes (no operator action required).** diff --git a/VERSION b/VERSION index 1e0c609..4f9b378 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.51.1 +0.52.0 diff --git a/backend/.env.example b/backend/.env.example index 9fa08d9..71cd1b1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -130,3 +130,16 @@ CLOUDFLARE_TURNSTILE_SECRET= # config drift surfaces as a loud 500 rather than a silent abuse- # defense disablement. TURNSTILE_REQUIRED=false + +# --- Deployed-environment E2E test auth (v0.52.0) --- +# DANGER: NEVER set these on a production deployment. Together they +# enable `POST /auth/test/login`, which mints an authenticated OWNER +# session for the one configured email without any OTC/email round trip +# — it exists only to run the Playwright E2E suite against a deployed +# pre-prod (PPE) host that has no Mailpit sink. The route is fail-closed: +# it returns 404 unless BOTH vars below are set, requires the caller to +# present E2E_TEST_AUTH_SECRET in the `X-Test-Auth-Secret` header +# (constant-time compare), and only ever mints the single configured +# email (any other → 403). Leave BOTH unset everywhere except PPE. +# E2E_TEST_AUTH_EMAIL=e2e-owner@example.test +# E2E_TEST_AUTH_SECRET= # a Secret Manager ref on real deployments; never a literal here diff --git a/backend/app/main.py b/backend/app/main.py index 3e0acd6..418e51f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -66,6 +66,13 @@ class OtcVerifyBody(BaseModel): trust_device: bool = False +class TestLoginBody(BaseModel): + # The single configured test identity to sign in as. Must equal + # E2E_TEST_AUTH_EMAIL (case-insensitive) or the request is refused — + # see `/auth/test/login`. + email: str = Field(min_length=3, max_length=320) + + class PasscodeSetBody(BaseModel): passcode: str = Field(min_length=1, max_length=64) @@ -101,6 +108,19 @@ async def lifespan(app: FastAPI): config = load_config() db.run_migrations(config) db.init(config) + # v0.52.0: shout if the deployed-env E2E test-auth shortcut is live. + # It mints owner sessions for one configured identity (see + # `/auth/test/login`); it must only ever be on for a pre-prod (PPE) + # host. A loud startup line means an accidental prod enablement is + # visible in the logs rather than silent. + if os.environ.get("E2E_TEST_AUTH_SECRET", "").strip() and os.environ.get( + "E2E_TEST_AUTH_EMAIL", "" + ).strip(): + log.warning( + "E2E TEST-AUTH IS ENABLED: POST /auth/test/login will mint an owner " + "session for %s. This must NEVER be set on production.", + os.environ["E2E_TEST_AUTH_EMAIL"].strip(), + ) gitea = Gitea(config) # §22 framework heal: reconcile a divergent default-project collection id # (migration 029's ≥2-projects seed names it after the project, e.g. 'ohm', @@ -386,6 +406,61 @@ def _oauth_router(config) -> APIRouter: "needs_profile": needs_profile, } + # --------------------------------------------------------------- + # v0.52.0: deployed-environment E2E test-auth shortcut. + # + # Running the Playwright E2E suite against a *deployed* environment + # (PPE) is the §9 pre-prod gate. But the deployed env has neither of + # the two scaffolds the Tier-1 docker stack relies on for auth: a + # Mailpit sink to read the OTC code from, and direct SQLite access to + # inject a granted-owner row. This endpoint replaces both with a + # single gated gesture: it mints an authenticated OWNER session for + # one pre-configured throwaway identity. + # + # It is FAIL-CLOSED and must never function in production: + # * 404 unless BOTH `E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL` + # are set — a prod deployment that sets neither cannot be coaxed + # into minting a session, and the route is invisible. + # * The caller must present the shared secret in `X-Test-Auth-Secret` + # (constant-time compare); a wrong/absent secret 404s (the route + # does not advertise itself to an unauthenticated caller). + # * Only the one configured email may be minted; any other address + # is refused (403). So an enabled PPE exposes exactly one + # throwaway owner identity, with the secret as the trust boundary. + # + # The hard secrets rule (§6.3) holds: the secret is a Secret Manager + # ref injected as env on the VM (never a literal in the repo), and the + # E2E runner presents it from SM at runtime (never echoed). + @router.post("/auth/test/login") + async def test_login(body: TestLoginBody, request: Request): + secret = os.environ.get("E2E_TEST_AUTH_SECRET", "").strip() + configured_email = os.environ.get("E2E_TEST_AUTH_EMAIL", "").strip() + # Feature off (the default, incl. production): route is invisible. + if not secret or not configured_email: + raise HTTPException(404, "Not Found") + presented = request.headers.get("x-test-auth-secret", "") + if not secrets.compare_digest(presented, secret): + # Don't reveal that the route exists to a caller without the + # secret — mirror the "off" shape exactly. + raise HTTPException(404, "Not Found") + if body.email.strip().lower() != configured_email.lower(): + raise HTTPException(403, "email not permitted") + + # Provision-or-link the row, then force it to a granted owner so + # the metadata write paths (SLICE-4/5) accept it — the deployed + # equivalent of the Tier-1 docker-compose backend-seed owner row. + user = otc.provision_or_link_user(body.email) + db.conn().execute( + "UPDATE users SET role = 'owner', permission_state = 'granted', " + "last_seen_at = datetime('now') WHERE id = ?", + (user.user_id,), + ) + db.conn().commit() + user.role = "owner" + user.permission_state = "granted" + auth.store_session(request, user) + return {"ok": True} + # --------------------------------------------------------------- # v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8). # diff --git a/backend/tests/test_e2e_test_auth_vertical.py b/backend/tests/test_e2e_test_auth_vertical.py new file mode 100644 index 0000000..6a6deee --- /dev/null +++ b/backend/tests/test_e2e_test_auth_vertical.py @@ -0,0 +1,165 @@ +"""End-to-end integration tests for the deployed-environment E2E +test-auth path (`POST /auth/test/login`). + +This endpoint is a **deliberately gated auth shortcut** for running the +Playwright E2E suite against a *deployed* environment (PPE) that has no +Mailpit OTC sink and no direct SQLite access to inject an owner row — +the two scaffolds the Tier-1 stack relies on. It mints an authenticated +**owner** session for a single, pre-configured test identity, but ONLY +when the deployment has explicitly opted in by setting BOTH +`E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`. It is fail-closed: + + * Off by default — with neither (or only one) env var set, the route + is invisible (404), so a production deployment that never sets them + cannot be coaxed into minting a session. + * Even when enabled, it requires the caller to present the shared + secret in the `X-Test-Auth-Secret` header (constant-time compare), + and it will only mint a session for the one configured email — any + other address is refused (403). So the blast radius of an enabled + PPE is a single throwaway owner identity, and the secret is the + trust boundary. + +The tests below pin every branch of that gate. +""" +from __future__ import annotations + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + tmp_env, +) + +SECRET = "ppe-e2e-shared-secret-value" +EMAIL = "e2e-owner@example.test" + + +def test_test_login_is_404_when_disabled(app_with_fake_gitea): + """Neither env var set (the default, incl. production) → the route + does not exist.""" + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post( + "/auth/test/login", + json={"email": EMAIL}, + headers={"X-Test-Auth-Secret": SECRET}, + ) + assert r.status_code == 404, r.text + + +def test_test_login_is_404_when_only_email_is_set(app_with_fake_gitea, monkeypatch): + """Half-configured (email but no secret) must NOT open the route — + a framework auth shortcut gated only by a known email would be far + too weak.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL) + monkeypatch.delenv("E2E_TEST_AUTH_SECRET", raising=False) + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post( + "/auth/test/login", + json={"email": EMAIL}, + headers={"X-Test-Auth-Secret": SECRET}, + ) + assert r.status_code == 404, r.text + + +def test_test_login_refuses_wrong_secret(app_with_fake_gitea, monkeypatch): + """Enabled, but a bad/absent secret → 404 (don't advertise the + route's existence to an unauthenticated caller).""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET) + monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL) + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + # Wrong secret. + r = client.post( + "/auth/test/login", + json={"email": EMAIL}, + headers={"X-Test-Auth-Secret": "not-the-secret"}, + ) + assert r.status_code == 404, r.text + # Absent secret. + r = client.post("/auth/test/login", json={"email": EMAIL}) + assert r.status_code == 404, r.text + + +def test_test_login_refuses_unconfigured_email(app_with_fake_gitea, monkeypatch): + """Right secret but an email other than the single configured + identity → 403. Even a secret-bearer can only mint the one test + owner.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET) + monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL) + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post( + "/auth/test/login", + json={"email": "someone-else@example.test"}, + headers={"X-Test-Auth-Secret": SECRET}, + ) + assert r.status_code == 403, r.text + + +def test_test_login_mints_owner_session(app_with_fake_gitea, monkeypatch): + """The happy path: right secret + configured email → an authenticated + session whose user is a GRANTED OWNER (so the metadata write paths — + SLICE-4 edit, SLICE-5 bulk — accept it), persisted on a fresh + `users` row.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET) + monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL) + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post( + "/auth/test/login", + json={"email": EMAIL}, + headers={"X-Test-Auth-Secret": SECRET}, + ) + assert r.status_code == 200, r.text + + # The session cookie now surfaces an authenticated owner. + me = client.get("/api/auth/me").json() + assert me["authenticated"] is True + assert me["user"]["email"] == EMAIL + assert me["user"]["role"] == "owner" + assert me["user"]["permission_state"] == "granted" + + # Idempotent: a second login reuses the same row (still owner). + r = client.post( + "/auth/test/login", + json={"email": EMAIL}, + headers={"X-Test-Auth-Secret": SECRET}, + ) + assert r.status_code == 200, r.text + + from app import db + rows = db.conn().execute( + "SELECT role, permission_state FROM users WHERE email = ? COLLATE NOCASE", + (EMAIL,), + ).fetchall() + assert len(rows) == 1 + assert rows[0]["role"] == "owner" + assert rows[0]["permission_state"] == "granted" + + +def test_test_login_is_case_insensitive_on_email(app_with_fake_gitea, monkeypatch): + """The configured-email check matches case-insensitively, mirroring + how the rest of the auth stack treats email.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET) + monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL) + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post( + "/auth/test/login", + json={"email": EMAIL.upper()}, + headers={"X-Test-Auth-Secret": SECRET}, + ) + assert r.status_code == 200, r.text diff --git a/docs/design/2026-06-07-deployed-env-e2e-harness.md b/docs/design/2026-06-07-deployed-env-e2e-harness.md new file mode 100644 index 0000000..7a1a887 --- /dev/null +++ b/docs/design/2026-06-07-deployed-env-e2e-harness.md @@ -0,0 +1,121 @@ +# 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. diff --git a/e2e/lib/auth.js b/e2e/lib/auth.js index eecdabc..f90317b 100644 --- a/e2e/lib/auth.js +++ b/e2e/lib/auth.js @@ -1,11 +1,36 @@ import { waitForLatestOtc, clearMailpit } from './mailpit.js' -// Sign in through the §6.2 OTC path, leaving the rfc_session cookie on the -// page's browser context (page.request shares the page context's cookie jar, -// so a subsequent page.goto is authenticated). The Tier-1 seed pre-provisions -// `e2e-owner@example.test` as a granted deployment owner, so signing in as that -// address yields write access to the metadata edit/bulk paths (SLICE-4/5). +// Sign in and leave the rfc_session cookie on the page's browser context +// (page.request shares the page context's cookie jar, so a subsequent +// page.goto is authenticated). Two paths, chosen by environment: +// +// * DEPLOYED (PPE): when E2E_TEST_AUTH_SECRET is set, use the gated +// `/auth/test/login` shortcut (v0.52.0). The deployed env has no +// Mailpit sink to read an OTC code from, so the suite presents the +// shared secret and the server mints an owner session for the one +// configured E2E_TEST_AUTH_EMAIL. See backend/app/main.py. +// * LOCAL (Tier-1 docker stack): no secret set → the original §6.2 OTC +// path through Mailpit. The Tier-1 backend-seed pre-grants +// e2e-owner@example.test as a deployment owner, so signing in as that +// address yields write access (SLICE-4/5). +// +// OWNER_EMAIL is the identity both paths sign in as: E2E_OWNER_EMAIL when +// set (PPE points it at E2E_TEST_AUTH_EMAIL), else the Tier-1 default. +export const OWNER_EMAIL = + process.env.E2E_OWNER_EMAIL || 'e2e-owner@example.test' + export async function signIn(page, email) { + const secret = process.env.E2E_TEST_AUTH_SECRET + if (secret) { + const r = await page.request.post('/auth/test/login', { + data: { email }, + headers: { 'X-Test-Auth-Secret': secret }, + }) + if (!r.ok()) throw new Error(`test-auth login failed: ${r.status()} ${await r.text()}`) + return + } + + // Local Tier-1 OTC path through Mailpit. await clearMailpit() const req = await page.request.post('/auth/otc/request', { data: { email } }) if (!req.ok()) throw new Error(`otc request failed: ${req.status()}`) @@ -13,5 +38,3 @@ export async function signIn(page, email) { const verify = await page.request.post('/auth/otc/verify', { data: { email, code } }) if (!verify.ok()) throw new Error(`otc verify failed: ${verify.status()}`) } - -export const OWNER_EMAIL = 'e2e-owner@example.test' diff --git a/frontend/package.json b/frontend/package.json index d560e74..53e930a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.51.1", + "version": "0.52.0", "type": "module", "scripts": { "dev": "vite", diff --git a/testing/seed-ppe.sh b/testing/seed-ppe.sh new file mode 100755 index 0000000..2ce267a --- /dev/null +++ b/testing/seed-ppe.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env sh +set -eu + +# PPE E2E content seed (§9 deployed-environment harness). +# +# The Tier-1 docker seed (seed-gitea.sh) stands up a throwaway Gitea. PPE +# instead runs against the REAL Gitea (git.wiggleverse.org) — which it +# shares with prod. To exercise the §22.4a metadata UI (faceted filter +# SLICE-3, edit panel SLICE-4, bulk bar SLICE-5) on PPE WITHOUT touching +# real OHM content, this script seeds a DEDICATED, PPE-only registry + +# content repo: +# +# wiggleverse/rfc-registry-ppe — PPE's own project registry (prod +# keeps using wiggleverse/rfc-registry, +# so prod is never affected). +# wiggleverse/rfc-app-ppe-content — content for the one project the PPE +# registry describes: a default +# (document) collection + a faceted +# `bdd` named collection with a +# fields: schema + three entries. +# +# Point PPE at the PPE registry with: +# flotilla-core overlay set rfc-app-ppe REGISTRY_REPO=rfc-registry-ppe +# The app's startup reconciler sweep loads this content into cached_rfcs +# on the next deploy/restart (no webhook needed for the initial load). +# +# Auth: pass a Gitea token with org repo-create + content-write scope via +# GITEA_TOKEN (the wiggleverse admin token — see the wgl-gitea-admin +# skill; never echo it). The collection path the E2E suite hits is +# /p/ohm/c/bdd, so the seeded project id is `ohm` (matching the Tier-1 +# seed and DEFAULT_PROJECT_ID=ohm) and the collection is `bdd`. +# +# Idempotent: repos/files that already exist are left as-is. Pass +# RESEED=1 to force-overwrite the three entry files back to their seed +# values (so a re-run restores SLICE-4/5's expected preconditions). + +GITEA="${GITEA_URL:-https://git.wiggleverse.org}" +ORG="${GITEA_ORG:-wiggleverse}" +REGISTRY_REPO="${REGISTRY_REPO:-rfc-registry-ppe}" +CONTENT_REPO="${CONTENT_REPO:-rfc-app-ppe-content}" +PROJECT_ID="${DEFAULT_PROJECT_ID:-ohm}" +TOKEN="${GITEA_TOKEN:?set GITEA_TOKEN to a wiggleverse-org admin/bot token (do not echo it)}" +RESEED="${RESEED:-0}" + +api() { curl -s -H "Authorization: token $TOKEN" "$@"; } + +echo "seed-ppe: target $GITEA org=$ORG registry=$REGISTRY_REPO content=$CONTENT_REPO project=$PROJECT_ID" + +ensure_repo() { + if api -o /dev/null -w '%{http_code}' "$GITEA/api/v1/repos/$ORG/$1" | grep -q '^200$'; then + echo "seed-ppe: repo $ORG/$1 exists" + return 0 + fi + echo "seed-ppe: creating repo $ORG/$1 (private)" + api -X POST "$GITEA/api/v1/orgs/$ORG/repos" -H 'Content-Type: application/json' \ + -d "{\"name\":\"$1\",\"auto_init\":true,\"default_branch\":\"main\",\"private\":true}" >/dev/null \ + || { echo "seed-ppe: failed to create $1" ; exit 1; } +} + +# file_sha -> prints the blob sha if the file exists, else empty +file_sha() { + api "$GITEA/api/v1/repos/$ORG/$1/contents/$2?ref=main" \ + | sed -n 's/.*"sha":"\([0-9a-f]*\)".*/\1/p' | head -1 +} + +# put_file [force] +# Creates the file if absent. If it exists: skipped, unless force=1, in +# which case it is updated in place (PUT with the current sha). +put_file() { + _repo="$1"; _path="$2"; _content="$3"; _force="${4:-0}" + _b64=$(printf '%s' "$_content" | base64 | tr -d '\n') + _sha=$(file_sha "$_repo" "$_path") + if [ -n "$_sha" ]; then + if [ "$_force" = "1" ]; then + echo "seed-ppe: updating $_repo/$_path" + api -X PUT "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \ + -H 'Content-Type: application/json' \ + -d "{\"message\":\"reseed $_path\",\"content\":\"$_b64\",\"sha\":\"$_sha\",\"branch\":\"main\"}" >/dev/null \ + || echo "seed-ppe: update $_repo/$_path failed, continuing" + else + echo "seed-ppe: $_repo/$_path exists, leaving as-is" + fi + return 0 + fi + echo "seed-ppe: creating $_repo/$_path" + api -X POST "$GITEA/api/v1/repos/$ORG/$_repo/contents/$_path" \ + -H 'Content-Type: application/json' \ + -d "{\"message\":\"seed $_path\",\"content\":\"$_b64\",\"branch\":\"main\"}" >/dev/null \ + || { echo "seed-ppe: create $_repo/$_path failed" ; exit 1; } +} + +ensure_repo "$REGISTRY_REPO" +ensure_repo "$CONTENT_REPO" + +# The PPE registry describes a single project `ohm` whose content lives in +# the dedicated PPE content repo. +put_file "$REGISTRY_REPO" "projects.yaml" "deployment: + name: RFC PPE + tagline: rfc-app pre-prod (E2E fixtures) +projects: + - id: $PROJECT_ID + name: OHM + type: document + content_repo: $CONTENT_REPO + visibility: public +" + +# Default (document) collection — one entry under rfcs/. +put_file "$CONTENT_REPO" "rfcs/intro.md" "--- +slug: intro +title: Intro +state: active +id: RFC-0001 +owners: [ben.stull] +--- + +# Intro + +Seed entry for the default (document) collection on PPE. +" + +# Faceted named collection 'bdd' with a fields: schema (§22.4a). +put_file "$CONTENT_REPO" "bdd/.collection.yaml" "type: bdd +visibility: public +name: BDD Scenarios +fields: + priority: + type: enum + values: [P0, P1, P2] + label: Priority + tags: + type: tags + label: Tags +" + +# Three entries with varied priority/tags so facets have counts and the +# bulk bar has multiple selectable rows. Force-overwritten when RESEED=1 +# so a re-run restores SLICE-4 (checkout-returning starts P1) and SLICE-5 +# (two P0 entries) preconditions. +put_file "$CONTENT_REPO" "bdd/rfcs/checkout-guest.md" "--- +slug: checkout-guest +title: Guest checkout +state: active +priority: P0 +tags: [checkout, payments] +--- + +Guest checkout scenario. +" "$RESEED" +put_file "$CONTENT_REPO" "bdd/rfcs/checkout-returning.md" "--- +slug: checkout-returning +title: Returning-customer checkout +state: active +priority: P1 +tags: [checkout] +--- + +Returning-customer checkout scenario. +" "$RESEED" +put_file "$CONTENT_REPO" "bdd/rfcs/search-facets.md" "--- +slug: search-facets +title: Faceted search +state: active +priority: P0 +tags: [search] +--- + +Faceted search scenario. +" "$RESEED" + +echo "seed-ppe: done. Set REGISTRY_REPO=$REGISTRY_REPO on rfc-app-ppe and redeploy."