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