213 lines
7.5 KiB
JavaScript
213 lines
7.5 KiB
JavaScript
/**
|
|
* 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)
|