Skip to content

Commit 9e674bc

Browse files
feat(e2e): Playwright AUTH-004 PR-gate against prod (Layer 1) (#153)
Per-PR Chromium smoke against api.instanode.dev. Three asserts: 1. CORS preflight returns ACAO=<web_origin> and ACAC=true 2. cross-origin POST from web origin completes (no 'Failed to fetch') 3. POST /auth/email/start returns 202 {ok:true} Closes the pre-merge gap for the 2026-05-29 → 2026-05-30 login regression class. The worker auth-probe catches it 5 min post-deploy; the OpenAPI contract CI catches schema drift; this catches the end-to-end browser traversal at PR time. Bounded ~3s test + npm/Playwright setup ~90s. Triggers: pull_request to main, push to main, workflow_dispatch (with optional E2E_API_URL/E2E_WEB_ORIGIN override), and repository_dispatch type 'auth-contract-e2e-from-api' (api repo will fire this from its CI in a follow-up PR — closes cross-repo gap). Local verify: 3/3 passed against PROD in 2.8s. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b156f89 commit 9e674bc

3 files changed

Lines changed: 376 additions & 0 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Layer-1 PR-gate: AUTH-004 CORS contract + magic-link round-trip
2+
#
3+
# Why: the 2026-05-29 → 2026-05-30 login outage had THREE stacked failures
4+
# (web missing /auth/exchange POST, web sending Accept:application/json,
5+
# api missing access-control-allow-credentials). The worker auth-probe
6+
# catches it 5 min post-deploy; the OpenAPI contract CI catches schema
7+
# drift at PR time. Neither catches it END-TO-END IN A BROWSER before
8+
# merge. This workflow does — see e2e/auth-contract.spec.ts.
9+
#
10+
# What it runs:
11+
# - Three Chromium-driven asserts against PROD api.instanode.dev +
12+
# instanode.dev (override with E2E_API_URL / E2E_WEB_ORIGIN).
13+
# - Bounded under 2 minutes (live tests are ~3s; lion's share is
14+
# npm ci + Chromium download).
15+
#
16+
# Triggers:
17+
# - pull_request to main (any path — auth surface is implicit to every PR).
18+
# - push to main (post-merge canary).
19+
# - workflow_dispatch (manual smoke).
20+
# - repository_dispatch with type `auth-contract-e2e-from-api` (fired by
21+
# the api repo's CI workflow when an api PR opens/pushes — closes the
22+
# cross-repo gap, since an api-side CORS regression would otherwise
23+
# ship without this gate firing).
24+
25+
name: Auth contract E2E (PR gate)
26+
27+
on:
28+
pull_request:
29+
branches: [main]
30+
push:
31+
branches: [main]
32+
workflow_dispatch:
33+
inputs:
34+
api_url:
35+
description: 'Override E2E_API_URL'
36+
required: false
37+
default: 'https://api.instanode.dev'
38+
web_origin:
39+
description: 'Override E2E_WEB_ORIGIN'
40+
required: false
41+
default: 'https://instanode.dev'
42+
repository_dispatch:
43+
types: [auth-contract-e2e-from-api]
44+
45+
concurrency:
46+
# Cancel an in-flight run when a new commit lands on the same PR.
47+
# Different PRs / triggers still run in parallel.
48+
group: auth-contract-${{ github.workflow }}-${{ github.ref }}
49+
cancel-in-progress: true
50+
51+
jobs:
52+
auth-contract-e2e:
53+
name: Auth contract smoke against prod
54+
runs-on: ubuntu-latest
55+
timeout-minutes: 5
56+
env:
57+
# SECURITY: never interpolate raw repository_dispatch client_payload
58+
# values into `run:` scripts (workflow-injection sink). Pipe through
59+
# env: and let the validate step sanitise. workflow_dispatch inputs
60+
# are gated by repo write permission so are lower-risk but get the
61+
# same treatment for consistency.
62+
RAW_API_URL: ${{ github.event.inputs.api_url || github.event.client_payload.api_url || '' }}
63+
RAW_WEB_ORIGIN: ${{ github.event.inputs.web_origin || github.event.client_payload.web_origin || '' }}
64+
TRIGGER: ${{ github.event_name }}
65+
steps:
66+
- uses: actions/checkout@v6
67+
68+
- uses: actions/setup-node@v6
69+
with:
70+
node-version: '22'
71+
cache: 'npm'
72+
73+
- name: Validate + resolve E2E targets
74+
# Allowlist the only hosts this smoke is permitted to drive. An
75+
# attacker who fires a repository_dispatch with a hostile api_url
76+
# cannot redirect the test to an attacker-controlled origin and
77+
# exfiltrate the GITHUB_TOKEN — the value is rejected here.
78+
# `set -u` would NOT catch the injection; explicit validation does.
79+
env:
80+
DEFAULT_API_URL: https://api.instanode.dev
81+
DEFAULT_WEB_ORIGIN: https://instanode.dev
82+
run: |
83+
set -euo pipefail
84+
api="${RAW_API_URL:-$DEFAULT_API_URL}"
85+
web="${RAW_WEB_ORIGIN:-$DEFAULT_WEB_ORIGIN}"
86+
case "$api" in
87+
https://api.instanode.dev|https://api.instanode.dev/) ;;
88+
*) echo "::error::E2E_API_URL '$api' not in allowlist {https://api.instanode.dev}"; exit 1 ;;
89+
esac
90+
case "$web" in
91+
https://instanode.dev|https://instanode.dev/|https://www.instanode.dev|https://www.instanode.dev/) ;;
92+
*) echo "::error::E2E_WEB_ORIGIN '$web' not in allowlist {https://instanode.dev,https://www.instanode.dev}"; exit 1 ;;
93+
esac
94+
# Strip trailing slash for stable comparison in the spec.
95+
api="${api%/}"
96+
web="${web%/}"
97+
echo "E2E_API_URL=$api" >> "$GITHUB_ENV"
98+
echo "E2E_WEB_ORIGIN=$web" >> "$GITHUB_ENV"
99+
echo "Resolved E2E_API_URL=$api E2E_WEB_ORIGIN=$web trigger=$TRIGGER"
100+
101+
- run: npm ci
102+
103+
# Only Chromium — this is a smoke, not a cross-browser matrix.
104+
- run: npx playwright install --with-deps chromium
105+
106+
- name: Run auth-contract smoke
107+
run: npx playwright test --config=playwright.auth-contract.config.ts
108+
109+
# Upload trace + screenshots on failure so the PR author can replay
110+
# the exact browser session locally (`npx playwright show-trace ...`).
111+
- name: Upload Playwright trace on failure
112+
if: failure()
113+
uses: actions/upload-artifact@v4
114+
with:
115+
name: auth-contract-trace-${{ github.run_id }}
116+
path: |
117+
test-results/
118+
playwright-report-auth-contract/
119+
if-no-files-found: ignore
120+
retention-days: 14

e2e/auth-contract.spec.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Layer-1 PR-gate smoke test for the AUTH-004 CORS contract + magic-link round-trip.
2+
//
3+
// Background — the 2026-05-29 → 2026-05-30 login regression class:
4+
// 1. instanode-web missing the /auth/exchange cookie-exchange POST.
5+
// 2. instanode-web sending Accept:application/json on /auth/exchange (forced
6+
// a preflight the api's PreflightAllowlist rejected → 403 → "Failed to fetch").
7+
// 3. api missing access-control-allow-credentials (ACAC) on the preflight
8+
// response.
9+
//
10+
// Existing gates:
11+
// - The worker auth-probe catches the regression 5 minutes POST-deploy.
12+
// - The OpenAPI contract CI catches schema drift at PR time.
13+
// - NOTHING catches it BEFORE merge end-to-end in a real browser. This spec
14+
// closes that gap. It runs on every PR and asserts:
15+
// (1) the api responds to a CORS preflight from the web origin with
16+
// ACAO=<origin> AND ACAC=true,
17+
// (2) a real browser-fetched cross-origin POST to /auth/exchange
18+
// completes the CORS traversal (no "Failed to fetch"),
19+
// (3) the /auth/email/start magic-link endpoint returns 202 + {ok:true}.
20+
//
21+
// The test runs against PROD by default (E2E_API_URL / E2E_WEB_ORIGIN). Set
22+
// the env vars to point at a staging api+web pair to gate pre-prod deploys.
23+
//
24+
// Layer 2 (docker-compose) is a separate, follow-up spec — see the agent brief.
25+
26+
import { expect, test } from '@playwright/test'
27+
28+
const API_URL = process.env.E2E_API_URL ?? 'https://api.instanode.dev'
29+
const WEB_ORIGIN = process.env.E2E_WEB_ORIGIN ?? 'https://instanode.dev'
30+
31+
// Magic-link probe address. The api Start handler:
32+
// - validates the address via mail.ParseAddress + 254-char cap (400 on fail),
33+
// - always returns 202 regardless of whether the email exists (defeats
34+
// enumeration),
35+
// - logs+drops the send through Brevo (which is currently unvalidated in
36+
// prod — see CLAUDE.md known design gap), so the address never actually
37+
// receives anything. That's the point: we test the API leg only, NOT email
38+
// delivery (which would be flaky and is covered by the worker auth-probe).
39+
const PROBE_EMAIL = 'probe-pr-gate@instanode.dev'
40+
41+
test.describe('AUTH-004 CORS contract — PR-gate smoke', () => {
42+
test.describe.configure({ mode: 'serial' })
43+
44+
// Test 1 — CORS preflight contract.
45+
//
46+
// If the api returns 4xx, missing access-control-allow-origin, or
47+
// missing access-control-allow-credentials, the browser-side fetch in
48+
// LoginCallbackPage.tsx fails with "TypeError: Failed to fetch" and the
49+
// user is stuck on /login/callback forever. The 2026-05-29 regression
50+
// shipped a preflight WITHOUT ACAC — this assertion would have caught it
51+
// before merge.
52+
test('preflight returns ACAO=<web_origin> and ACAC=true', async ({ request }) => {
53+
const resp = await request.fetch(`${API_URL}/auth/exchange`, {
54+
method: 'OPTIONS',
55+
headers: {
56+
Origin: WEB_ORIGIN,
57+
'Access-Control-Request-Method': 'POST',
58+
},
59+
failOnStatusCode: false,
60+
})
61+
62+
// The api uses fiber CORS middleware which replies 204 on a successful
63+
// preflight; accept 200 too in case the framework changes.
64+
expect(
65+
[200, 204].includes(resp.status()),
66+
`expected 200 or 204 preflight status, got ${resp.status()} — body: ${await resp.text().catch(() => '<unreadable>')}`,
67+
).toBe(true)
68+
69+
const headers = resp.headers()
70+
const acao = headers['access-control-allow-origin']
71+
const acac = headers['access-control-allow-credentials']
72+
73+
expect(
74+
acao,
75+
`MISSING access-control-allow-origin on preflight. This breaks the cross-origin POST from ${WEB_ORIGIN}/login/callback to ${API_URL}/auth/exchange — the browser will reject the response and the user will see "Failed to fetch". Restore the CORS allowlist for ${WEB_ORIGIN}.`,
76+
).toBe(WEB_ORIGIN)
77+
78+
expect(
79+
acac,
80+
`MISSING access-control-allow-credentials: true on preflight. The web SPA fetches /auth/exchange with credentials:'include' (it needs the instanode_session_exchange cookie). Without ACAC the browser drops the cookie, the api returns 400 cookie_missing_or_expired, and login silently fails. Re-add ACAC to the CORS middleware response.`,
81+
).toBe('true')
82+
})
83+
84+
// Test 2 — Real browser cross-origin POST completes (no "Failed to fetch").
85+
//
86+
// This is the load-bearing test. It mirrors what LoginCallbackPage.tsx
87+
// does in production: navigate to the web origin, then `fetch()` the api
88+
// /auth/exchange endpoint from inside the page (so the browser enforces
89+
// CORS). The CORS-fixed prod returns 400 (no cookie) — that's fine. What
90+
// we assert is the fetch RESOLVED, not that it succeeded — i.e. the
91+
// CORS traversal worked and the browser didn't block the response.
92+
//
93+
// A regression that strips ACAC, removes ACAO, or otherwise breaks the
94+
// simple-CORS path would make this fetch throw "TypeError: Failed to
95+
// fetch" in the page context — exactly the user-visible symptom of the
96+
// 2026-05-30 outage.
97+
test('cross-origin POST from web origin completes the CORS traversal', async ({ page }) => {
98+
// Intercept the navigation to the web origin and serve a minimal stub
99+
// page. We don't care what's IN the page — we only need the browser to
100+
// adopt the WEB_ORIGIN as the document's origin so the subsequent
101+
// fetch(API_URL/...) is genuinely cross-origin (the whole point of this
102+
// test). Avoiding the real /login download dodges flaky third-party
103+
// resources (analytics, fonts, etc.) and keeps the test bounded under
104+
// a second instead of timing out on a slow SSR/redirect.
105+
await page.route(`${WEB_ORIGIN}/__auth_contract_origin_stub`, async (route) => {
106+
await route.fulfill({
107+
status: 200,
108+
contentType: 'text/html',
109+
body: '<!doctype html><html><body>auth-contract stub origin</body></html>',
110+
})
111+
})
112+
const navResp = await page.goto(`${WEB_ORIGIN}/__auth_contract_origin_stub`, { waitUntil: 'load' })
113+
expect(
114+
navResp,
115+
`failed to navigate to ${WEB_ORIGIN} origin stub — Playwright returned no response.`,
116+
).not.toBeNull()
117+
// Sanity-check the document origin is what we expect — if Playwright
118+
// ever changes how route-fulfilled navigations are scoped, the test
119+
// would silently be same-origin (and miss the CORS bug it's meant to
120+
// catch). Fail loud instead.
121+
const docOrigin = await page.evaluate(() => window.location.origin)
122+
expect(
123+
docOrigin,
124+
`document origin after navigation was ${docOrigin}, expected ${WEB_ORIGIN}. ` +
125+
`The fetch below would not be cross-origin and would silently pass even with a broken CORS contract.`,
126+
).toBe(WEB_ORIGIN)
127+
128+
const result = await page.evaluate(
129+
async ({ apiUrl }) => {
130+
try {
131+
// Mirror LoginCallbackPage.tsx exchangeCookieForToken EXACTLY: no
132+
// custom headers (no Accept, no Content-Type) so the request stays
133+
// a "simple cross-origin request" per the CORS spec — no preflight.
134+
// credentials:'include' so the browser would send the bridge cookie
135+
// if it had one.
136+
const resp = await fetch(`${apiUrl}/auth/exchange`, {
137+
method: 'POST',
138+
credentials: 'include',
139+
})
140+
const body = await resp.text().catch(() => '')
141+
return { ok: true, status: resp.status, bodyLen: body.length }
142+
} catch (e: any) {
143+
// The diagnostic the 2026-05-30 users saw. Surface the literal
144+
// message so a future regression reports it inline in the CI log.
145+
return { ok: false, error: String(e?.message ?? e) }
146+
}
147+
},
148+
{ apiUrl: API_URL },
149+
)
150+
151+
expect(
152+
result.ok,
153+
`cross-origin POST threw — this is the EXACT user-visible login failure. ` +
154+
`Browser error: ${'error' in result ? result.error : ''}. ` +
155+
`Likely cause: api CORS middleware dropped access-control-allow-credentials ` +
156+
`or access-control-allow-origin for ${WEB_ORIGIN}.`,
157+
).toBe(true)
158+
159+
// 400 (cookie missing) is the expected response on a fresh probe — there
160+
// is no bridge cookie to exchange. 401 and 410 are also acceptable
161+
// (cookie semantics may change). Anything outside 4xx is a regression
162+
// worth investigating.
163+
if ('status' in result) {
164+
expect(
165+
result.status >= 400 && result.status < 500,
166+
`expected 4xx (cookie missing/expired) on a no-cookie exchange, got ${result.status}. ` +
167+
`200 would mean the api accepted an exchange with no bridge cookie — a major auth bug. ` +
168+
`5xx would mean the api is unhealthy.`,
169+
).toBe(true)
170+
}
171+
})
172+
173+
// Test 3 — Magic-link start endpoint returns 202 {ok:true}.
174+
//
175+
// The api MagicLinkHandler.Start always returns 202 (even when the email
176+
// doesn't exist, even when downstream email delivery fails) — that's
177+
// deliberate, it defeats enumeration. So this test is bounded: it asserts
178+
// the api LEG of the magic-link flow works. Email delivery itself is
179+
// covered by the Brevo webhook (which writes forwarder_sent.classification
180+
// — see CLAUDE.md rule 12) and the worker auth-probe, NOT this test.
181+
test('POST /auth/email/start returns 202 {ok:true}', async ({ request }) => {
182+
const resp = await request.fetch(`${API_URL}/auth/email/start`, {
183+
method: 'POST',
184+
headers: {
185+
'Content-Type': 'application/json',
186+
Origin: WEB_ORIGIN,
187+
},
188+
data: JSON.stringify({
189+
email: PROBE_EMAIL,
190+
return_to: `${WEB_ORIGIN}/login/callback`,
191+
}),
192+
failOnStatusCode: false,
193+
})
194+
195+
expect(
196+
resp.status(),
197+
`POST /auth/email/start should always return 202 (rejecting any other status would leak whether the email exists). ` +
198+
`Got ${resp.status()}. Body: ${await resp.text().catch(() => '<unreadable>')}`,
199+
).toBe(202)
200+
201+
const body = await resp.json().catch(() => null)
202+
expect(body, `/auth/email/start response body was not JSON`).not.toBeNull()
203+
expect(
204+
body?.ok,
205+
`/auth/email/start should return {ok:true}; got ${JSON.stringify(body)}`,
206+
).toBe(true)
207+
})
208+
})

playwright.auth-contract.config.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Dedicated Playwright config for the Layer-1 PR-gate auth-contract smoke.
2+
//
3+
// Why a separate config: the default playwright.config.ts boots a local Vite
4+
// dev server (webServer) and runs the whole e2e/ suite (which uses Vite-served
5+
// mocks). This spec is fundamentally different — it talks to PROD api +
6+
// web origin directly. No webServer needed; no other specs in scope.
7+
//
8+
// Invoked by:
9+
// npx playwright test --config=playwright.auth-contract.config.ts
10+
//
11+
// Env knobs (default to prod):
12+
// E2E_API_URL=https://api.instanode.dev
13+
// E2E_WEB_ORIGIN=https://instanode.dev
14+
15+
import { defineConfig, devices } from '@playwright/test'
16+
17+
export default defineConfig({
18+
testDir: './e2e',
19+
testMatch: ['auth-contract.spec.ts'],
20+
21+
// Smoke test — bounded run. The whole suite is 3 fast tests against live
22+
// prod; nothing should take more than a few seconds.
23+
timeout: 30_000,
24+
expect: { timeout: 10_000 },
25+
26+
fullyParallel: false, // 3 serial tests; parallelism not worth the noise.
27+
forbidOnly: !!process.env.CI,
28+
retries: process.env.CI ? 2 : 0,
29+
workers: 1,
30+
31+
reporter: process.env.CI
32+
? [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-auth-contract' }]]
33+
: [['list']],
34+
35+
use: {
36+
trace: 'retain-on-failure',
37+
screenshot: 'only-on-failure',
38+
// No baseURL — the spec uses absolute URLs (E2E_WEB_ORIGIN / E2E_API_URL)
39+
// so tests must be explicit about which origin they're hitting.
40+
},
41+
42+
projects: [
43+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
44+
],
45+
46+
// Deliberately no `webServer` — this spec hits prod, not a local Vite dev
47+
// server. Adding one here would slow CI by ~30s for no benefit.
48+
})

0 commit comments

Comments
 (0)