83eafe72ee
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>
166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
"""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
|