/** * Token-layer test — the headline behaviour of this migration. * * npm run test:token * * Covers, against a scripted fake server: * 1. proactive renewal inside the 60s skew window * 2. reactive 401 -> refresh -> retry, exactly once * 3. SINGLE-FLIGHT: N concurrent 401s produce ONE /users/refresh * 4. permissions survive a refresh (the response omits them) * 5. a rejected refresh token clears the session and fires onSessionExpired * 6. no infinite retry when the retry also 401s */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import esbuild from 'esbuild' import { JSDOM } from 'jsdom' const dom = new JSDOM('', { url: 'http://localhost:5173/' }) globalThis.window = dom.window globalThis.document = dom.window.document globalThis.localStorage = dom.window.localStorage Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) const outDir = mkdtempSync(join(tmpdir(), 'tf-token-')) const outFile = join(outDir, 'entry.mjs') await esbuild.build({ entryPoints: ['src/__smoke__/token.entry.js'], outfile: outFile, bundle: true, format: 'esm', platform: 'node', target: 'node20', logLevel: 'error', define: { 'import.meta.env': JSON.stringify({ VITE_API_BASE: '' }) }, }) const T = await import(pathToFileURL(outFile).href) // ------------------------------------------------------------------ harness let calls = [] let accessValid = 'A1' let refreshValid = 'R1' let refreshCount = 0 let rejectRefresh = false let alwaysUnauthorized = false function json(status, body) { return { ok: status >= 200 && status < 300, status, statusText: '', // apiClient reads text(); refresh.js reads json(). Provide both. text: async () => JSON.stringify(body), json: async () => body, } } globalThis.fetch = async (url, opts = {}) => { // apiClient builds absolute URLs via `new URL(...)`; refresh.js uses a // relative path. Normalise so the harness sees one shape. const path = String(url).replace(/^https?:\/\/[^/]+/, '') calls.push(path) if (path === '/users/refresh') { const sent = JSON.parse(opts.body).refresh_token if (rejectRefresh || sent !== refreshValid) return json(401, { detail: 'Invalid or expired refresh token' }) refreshCount += 1 accessValid = `A${refreshCount + 1}` refreshValid = `R${refreshCount + 1}` // the backend ROTATES both // NOTE: no `permissions` in `data` — this is the real backend's shape. return json(200, { access_token: accessValid, refresh_token: refreshValid, token_type: 'bearer', expires_in: 1800, data: { id: 1, name: 'Test User', email: 't@example.com' }, }) } const bearer = (opts.headers?.Authorization || '').replace('Bearer ', '') if (alwaysUnauthorized || bearer !== accessValid) return json(401, { detail: 'Could not validate credentials' }) return json(200, { data: { path }, status_code: 200 }) } function reset({ expiresIn = 1800 } = {}) { calls = [] refreshCount = 0 rejectRefresh = false alwaysUnauthorized = false accessValid = 'A1' refreshValid = 'R1' T.clearSession() T.setSession({ access_token: 'A1', refresh_token: 'R1', expires_in: expiresIn, data: { id: 1, name: 'Test User', permissions: ['jobs.view', 'candidates.view'] }, }) } const results = [] function check(name, pass, detail = '') { results.push({ name, pass, detail }) console.log(`${pass ? 'ok ' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`) } // ------------------------------------------------------------------ 1. proactive { reset({ expiresIn: 30 }) // already inside the 60s skew window await T.request('/jobs/fetch') const order = calls.join(' ') check( 'proactive renewal fires BEFORE any 401', calls[0] === '/users/refresh' && calls[1] === '/jobs/fetch' && refreshCount === 1, `calls: ${order}`, ) } // ------------------------------------------------------------------ 2. reactive { reset() // token not near expiry… accessValid = 'SOMETHING-ELSE' // …but the server rejects it anyway await T.request('/jobs/fetch') check( 'reactive 401 -> refresh -> retry (exactly one retry)', calls.filter((c) => c === '/jobs/fetch').length === 2 && refreshCount === 1, `calls: ${calls.join(' ')}`, ) } // ------------------------------------------------------------------ 3. SINGLE-FLIGHT { reset() accessValid = 'SOMETHING-ELSE' await Promise.all([ T.request('/jobs/fetch'), T.request('/candidates/fetch'), T.request('/roles/fetch'), T.request('/users/fetch'), T.request('/permissions/fetch'), T.request('/inbox/fetch'), ]) const refreshes = calls.filter((c) => c === '/users/refresh').length check( 'SINGLE-FLIGHT: 6 concurrent 401s produce exactly ONE /users/refresh', refreshes === 1, `saw ${refreshes} refresh call(s); the backend rotates the refresh token, so >1 orphans a pair`, ) } // ------------------------------------------------------------------ 3b. proactive single-flight { reset({ expiresIn: 10 }) await Promise.all([T.request('/a'), T.request('/b'), T.request('/c'), T.request('/d')]) const refreshes = calls.filter((c) => c === '/users/refresh').length check('SINGLE-FLIGHT: 4 concurrent proactive renewals produce ONE refresh', refreshes === 1, `saw ${refreshes}`) } // ------------------------------------------------------------------ 4. permissions survive { reset({ expiresIn: 30 }) await T.request('/jobs/fetch') const perms = T.getSession()?.data?.permissions check( 'permissions survive a refresh (response omits them)', Array.isArray(perms) && perms.includes('jobs.view'), `permissions after refresh: ${JSON.stringify(perms)}`, ) } // ------------------------------------------------------------------ 4b. rotation stored { reset({ expiresIn: 30 }) await T.request('/jobs/fetch') const s = T.getSession() check( 'both tokens rotate and are persisted', s.access_token === 'A2' && s.refresh_token === 'R2' && s.expires_at > Date.now(), `access=${s.access_token} refresh=${s.refresh_token}`, ) } // ------------------------------------------------------------------ 5. expired refresh { reset() accessValid = 'SOMETHING-ELSE' rejectRefresh = true let expiredFired = false T.setSessionExpiredHandler(() => { expiredFired = true }) let threw = false try { await T.request('/jobs/fetch') } catch { threw = true } check( 'rejected refresh token -> session cleared + onSessionExpired fired', threw && expiredFired && T.getSession() === null, `threw=${threw} handlerFired=${expiredFired} session=${T.getSession()}`, ) T.setSessionExpiredHandler(() => {}) } // ------------------------------------------------------------------ 6. no retry loop { reset() alwaysUnauthorized = true // refresh succeeds, but the resource still 401s let expiredFired = false T.setSessionExpiredHandler(() => { expiredFired = true }) let threw = false try { await T.request('/jobs/fetch') } catch { threw = true } const attempts = calls.filter((c) => c === '/jobs/fetch').length check( 'no infinite loop when the retry also 401s (deactivated user)', threw && expiredFired && attempts === 2, `resource attempts=${attempts} (expected exactly 2)`, ) T.setSessionExpiredHandler(() => {}) } rmSync(outDir, { recursive: true, force: true }) const failed = results.filter((r) => !r.pass).length console.log(failed ? `\n${failed}/${results.length} token checks FAILED` : `\nAll ${results.length} token checks passed`) process.exit(failed ? 1 : 0)