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:
@@ -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).
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user