|
| 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 | +}) |
0 commit comments