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>
This commit is contained in:
@@ -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).**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
#
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+30
-7
@@ -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'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.51.1",
|
||||
"version": "0.52.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Executable
+171
@@ -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 <repo> <path> -> 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 <repo> <path> <plaintext> [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."
|
||||
Reference in New Issue
Block a user