import { test, expect } from '@playwright/test' // AZ-457 — e2e variants for auth-surface scenarios that require the real // suite stack (admin/auth/login + admin/auth/refresh). // // FT-P-02 — 401 → refresh → retry against the real admin/ service // NFT-SEC-01 — bearer never written to localStorage / sessionStorage post-login // NFT-SEC-02 — document.cookie does not expose the refresh token value // NFT-SEC-03 — refresh cookie attributes Secure; HttpOnly; SameSite=Strict // // Profile: e2e (gated by docker compose stack — Risk 4 in AZ-456). Skipped // when running locally without the suite stack. // // Black-box discipline: every assertion is at the network, browser-storage, // or DOM surface. The tests do NOT import production modules (e2e bodies // only touch Playwright primitives). // // Seed login: `op_alice@test.local` is in fixtures/seeds.sql and the // admin/:test image accepts the test password set by ENABLE_TEST_ONLY_ENDPOINTS. const ALICE_EMAIL = 'op_alice@test.local' const ALICE_PASSWORD = 'TestPassword!23' // matches admin/:test seed password test.describe('AZ-457 e2e — auth surface', () => { test('FT-P-02 (rows 03, 12): 401 → refresh → retry against real admin/', async ({ page }) => { // Arrange — login via the real service and capture the bearer. await page.goto('/login') const loginResponse = await Promise.all([ page.waitForResponse( (r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST', ), page.getByLabel(/email/i).fill(ALICE_EMAIL).then(() => page.getByLabel(/password/i).fill(ALICE_PASSWORD), ).then(() => page.getByRole('button', { name: /sign in/i }).click()), ]) expect(loginResponse[0].status()).toBe(200) // Force the next /users/me call to 401, then let the retry succeed. let firstHit = true let refreshHits = 0 await page.route('**/api/admin/users/me', async (route) => { if (firstHit) { firstHit = false await route.fulfill({ status: 401 }) return } await route.continue() }) await page.route('**/api/admin/auth/refresh', async (route) => { refreshHits += 1 await route.continue() }) // Act — trigger an authed call. Any navigated-to admin page exercises // /api/admin/users/me on mount through the production AuthContext. await page.goto('/admin') // Assert — exactly one refresh observed, original request retried, page // reaches an authed surface (defensive: just verify no /login redirect). await expect.poll(() => refreshHits, { timeout: 10_000 }).toBe(1) await expect(page).not.toHaveURL(/\/login$/) }) test('NFT-SEC-01 (row 04) + NFT-SEC-02 (row 05): bearer not in storage; refresh-cookie not in document.cookie', async ({ page, context }) => { // Arrange — full login flow. await page.goto('/login') await page.getByLabel(/email/i).fill(ALICE_EMAIL) await page.getByLabel(/password/i).fill(ALICE_PASSWORD) const loginResp = await Promise.all([ page.waitForResponse( (r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST', ), page.getByRole('button', { name: /sign in/i }).click(), ]) const responseBody = await loginResp[0].json() const bearer: string = responseBody.token expect(bearer.length).toBeGreaterThan(0) // Wait for the post-login route to settle. await page.waitForLoadState('networkidle') // NFT-SEC-01 — neither localStorage nor sessionStorage contains the bearer. const stored = await page.evaluate(() => { const out: Record<'local' | 'session', Record> = { local: {}, session: {}, } for (let i = 0; i < localStorage.length; i += 1) { const k = localStorage.key(i)! out.local[k] = localStorage.getItem(k) ?? '' } for (let i = 0; i < sessionStorage.length; i += 1) { const k = sessionStorage.key(i)! out.session[k] = sessionStorage.getItem(k) ?? '' } return out }) const flat = JSON.stringify(stored) expect(flat, 'bearer leaked to localStorage / sessionStorage').not.toContain(bearer) // NFT-SEC-02 — JS-visible document.cookie does not expose the refresh token. const jsCookies = await page.evaluate(() => document.cookie) expect(jsCookies).not.toMatch(/refresh/i) expect(jsCookies).not.toContain(bearer) // Defence-in-depth: the actual refresh cookie IS present in the browser jar // (HttpOnly is invisible to JS but visible via context.cookies()). const allCookies = await context.cookies() const refreshCookie = allCookies.find((c) => /refresh/i.test(c.name)) expect(refreshCookie, 'refresh cookie should be set in the jar but invisible to JS').toBeDefined() }) test('NFT-SEC-03 (row 07): refresh cookie attributes — Secure, HttpOnly, SameSite=Strict', async ({ page, context }) => { // Arrange + Act await page.goto('/login') const loginResp = await Promise.all([ page.waitForResponse( (r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST', ), page.getByLabel(/email/i).fill(ALICE_EMAIL).then(() => page.getByLabel(/password/i).fill(ALICE_PASSWORD), ).then(() => page.getByRole('button', { name: /sign in/i }).click()), ]) // Inspect the Set-Cookie header from the login response. const setCookie = loginResp[0].headersArray() .filter((h) => h.name.toLowerCase() === 'set-cookie') .map((h) => h.value) .join(' ; ') expect(setCookie, 'Set-Cookie header missing').not.toEqual('') expect(setCookie).toMatch(/Secure/i) expect(setCookie).toMatch(/HttpOnly/i) expect(setCookie).toMatch(/SameSite\s*=\s*Strict/i) // Cross-check via the cookie jar (HttpOnly is visible to context.cookies()). const allCookies = await context.cookies() const refreshCookie = allCookies.find((c) => /refresh/i.test(c.name)) expect(refreshCookie).toBeDefined() if (refreshCookie) { expect(refreshCookie.httpOnly).toBe(true) expect(refreshCookie.secure).toBe(true) expect(refreshCookie.sameSite).toBe('Strict') } }) })