HR-ATS-Portal/frontend/token.test.mjs

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)