/** * Tests for hoover/auth_model.js * * Model-level tests: the 'check' action (session validation, including the * single refresh fallback on a 401) and the 'refresh' action (rotation). * * auth_model.js only pulls in model.js → reactivity.js (no DOM at import), * so the tests run under plain node with stubbed globals (fetch, * sessionStorage, document, window, timers). * * Run with `node tests/test-auth-model.js`. */ import { createAuthModel } from '../webui/static/hoover/auth_model.js'; import { modelRegister, modelFetch, getModel } from '../webui/static/hoover/model.js'; let passed = 0; let failed = 0; const tests = []; function test(name, fn) { tests.push({ name, fn }); } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } function assertEq(a, b, msg) { if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); } /* ── Stubs ─────────────────────────────────────────────────── */ function makeStorage(initial = {}) { const m = new Map(Object.entries(initial)); return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), keys: () => [...m.keys()], }; } /** Programmed URL → response queue. Missing routes are 500. */ function makeFetch() { const byUrl = new Map(); globalThis.fetch = async (url) => { const arr = byUrl.get(url) || []; const r = arr.length ? arr.shift() : { status: 500, body: { ok: false, error: 'unprogrammed route' } }; return { ok: r.status >= 200 && r.status < 300, status: r.status, json: async () => r.body, }; }; const state = { calls: [], route: (url, status, body) => { if (!byUrl.has(url)) byUrl.set(url, []); state.calls.push(url); byUrl.get(url).push({ status, body }); } }; return state; } /** Stub the TTL refresh timer so the node process never waits on it. */ const _timers = []; globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; }; globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; }; const storedSession = { 'vw:access': 'access-old', 'vw:refresh': 'refresh-old', 'vw:session_id': 'sess-old', 'vw:access_ttl': '900000', }; /** * Stubs + model registration for one scenario. * @param {{initialStorage?: object, hash?: string}} [cfg] * @returns {{calls: string[], route: function, events: Array}} */ function setup({ initialStorage = storedSession, hash = '#/dashboard' } = {}) { const { calls, route } = makeFetch(); globalThis.sessionStorage = makeStorage(initialStorage); globalThis.document = { location: { hash } }; const events = []; globalThis.window = { dispatchEvent: (e) => events.push(e), addEventListener: () => {}, }; modelRegister('auth', createAuthModel()); return { calls, route, events }; } /** Run the auth model action through the real modelFetch (dedup, hooks). */ async function act(action) { await modelFetch('auth', { action }); } /* ── Tests ─────────────────────────────────────────────────── */ test('check 200 returns verified identity on the stored tokens', async () => { const s = setup(); s.route('/api/auth/session', 200, { ok: true, data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } }, }); await act('check'); assertEq(s.calls.length, 1, 'session check only'); assertEq(s.calls[0], '/api/auth/session'); const data = getModel('auth').data; assertEq(data.token, 'access-old', 'stored access token kept'); assertEq(data.refresh, 'refresh-old', 'stored refresh token kept'); assertEq(data.session_id, 'sess-old', 'stored session_id kept'); assertEq(data.user?.username, 'alice', 'verified user merged'); assert(data.permissions?.firewall === 'rw', 'verified permissions merged'); }); test('check 401 with stored refresh token: one refresh, session preserved', async () => { const s = setup(); s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' }); s.route('/api/auth/refresh', 200, { ok: true, data: { tokens: { access_token: 'access-new', refresh_token: 'refresh-new', session_id: 'sess-new' }, user: { username: 'alice' }, permissions: { firewall: 'rw' }, access_ttl: 300, }, }); await act('check'); assertEq(s.calls.length, 2, 'exactly one refresh attempt'); assertEq(s.calls[0], '/api/auth/session', 'session check first'); assertEq(s.calls[1], '/api/auth/refresh', 'refresh fallback second'); const data = getModel('auth').data; assertEq(data.token, 'access-new', 'rotated access token'); assertEq(data.refresh, 'refresh-new', 'rotated refresh token'); assertEq(data.session_id, 'sess-new', 'rotated session_id binding wins'); assertEq(data.user?.username, 'alice', 'refreshed identity'); assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 'sess-new', 'storage carries rotated session_id'); assert(s.events.length === 0, 'no terminal event on recovered session'); }); test('check 401 without refresh token: terminal, no refresh attempted', async () => { const s = setup({ initialStorage: { 'vw:access': 'access-old', 'vw:session_id': 'sess-old' } }); s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' }); await act('check'); assertEq(s.calls.length, 1, 'session check only'); assertEq(s.calls[0], '/api/auth/session'); const data = getModel('auth').data; assert(!data?.token, 'terminal: no token'); assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared'); assertEq(globalThis.document.location.hash, '/login', 'redirected to login'); assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched'); }); test('check 401 with failed refresh: terminal', async () => { const s = setup(); s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' }); s.route('/api/auth/refresh', 401, { ok: false, error: 'invalid refresh token' }); await act('check'); assertEq(s.calls.length, 2, 'refresh was attempted'); assert(!getModel('auth').data?.token, 'terminal: no token'); assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared'); assertEq(globalThis.document.location.hash, '/login', 'redirected to login'); assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched'); }); test('refresh action rotates tokens; new session_id wins, omitted fields fall back', async () => { const s = setup(); s.route('/api/auth/refresh', 200, { ok: true, data: { tokens: { access_token: 'a2', refresh_token: 'r2', session_id: 's2' }, access_ttl: 300, user: { username: 'alice' }, permissions: { firewall: 'rw' }, }, }); await act('refresh'); assertEq(s.calls.length, 1, 'single refresh call'); assertEq(s.calls[0], '/api/auth/refresh'); const data = getModel('auth').data; assertEq(data.token, 'a2', 'rotated access token'); assertEq(data.refresh, 'r2', 'rotated refresh token'); assertEq(data.session_id, 's2', 'new session_id wins'); assertEq(data.ttl, 300 * 1000, 'ttl from access_ttl seconds → ms'); assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 's2', 'storage rotated'); assertEq(data.user?.username, 'alice', 'user from response'); }); /* ── exp-claim TTL tests ───────────────────────────────────── */ /** Base64url-encode a JSON object (JWT segment builder). */ function b64url(obj) { return btoa(JSON.stringify(obj)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** Build a structurally valid (unsigned) JWT whose exp is offsetSeconds from now. */ function makeJwt(offsetSeconds) { return [ b64url({ alg: 'HS256' }), b64url({ sub: 'alice', exp: Math.floor(Date.now() / 1000) + offsetSeconds, iat: Math.floor(Date.now() / 1000), type: 'access', session_id: 'sess-jwt', }), b64url({ sig: true }), ].join('.'); } /** Most recent defined entry in the captured timer queue. */ function lastTimer() { for (let i = _timers.length - 1; i >= 0; i--) if (_timers[i]) return _timers[i]; return null; } test('check 200: ttl is the token\'s remaining lifetime (exp claim), not the stored full TTL', async () => { const s = setup({ initialStorage: { 'vw:access': makeJwt(600), // expires in 10 min… 'vw:refresh': 'refresh-old', 'vw:session_id': 'sess-jwt', 'vw:access_ttl': '900000', // …but the stored full TTL says 15 min }, }); s.route('/api/auth/session', 200, { ok: true, data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } }, }); await act('check'); const ttl = getModel('auth').data.ttl; assert(ttl > 590 * 1000 && ttl <= 600 * 1000, `remaining ttl (~600s), not the stored 900s: got ${ttl}`); // scheduleRefresh fires at ttl - 60s — the timer must target the real expiry. const t = lastTimer(); assert(t && t.ms > 530 * 1000 && t.ms <= 540 * 1000, `refresh timer targets expiry - 60s: got ${t && t.ms}`); }); test('check 200: already-expired token falls back to the stored TTL (401 recovery path applies)', async () => { const s = setup({ initialStorage: { 'vw:access': makeJwt(-10), // already expired 'vw:refresh': 'refresh-old', 'vw:session_id': 'sess-jwt', 'vw:access_ttl': '900000', }, }); s.route('/api/auth/session', 200, { ok: true, data: { user: { username: 'alice' }, permissions: {} }, }); await act('check'); assertEq(getModel('auth').data.ttl, 900 * 1000, 'fallback to stored ttl'); }); test('check 200: non-JWT stored token falls back to the stored TTL', async () => { const s = setup(); // default storage carries the non-JWT 'access-old' s.route('/api/auth/session', 200, { ok: true, data: { user: { username: 'alice' }, permissions: {} }, }); await act('check'); assertEq(getModel('auth').data.ttl, 900 * 1000, 'fallback to stored ttl'); }); test('refresh action: rotated ttl comes from the new token\'s exp claim', async () => { const s = setup(); s.route('/api/auth/refresh', 200, { ok: true, data: { tokens: { access_token: makeJwt(450), refresh_token: 'r2', session_id: 's2' }, access_ttl: 300, // full TTL — must lose to the exp claim user: { username: 'alice' }, permissions: { firewall: 'rw' }, }, }); await act('refresh'); const ttl = getModel('auth').data.ttl; assert(ttl > 440 * 1000 && ttl <= 450 * 1000, `exp-based ttl (~450s), not access_ttl 300s: got ${ttl}`); }); test('login action: ttl comes from the issued token\'s exp claim', async () => { const s = setup(); await modelFetch('auth', { action: 'login', payload: { tokens: { access_token: makeJwt(900), refresh_token: 'r1', session_id: 's1' }, access_ttl: 900, user: { username: 'alice' }, permissions: { firewall: 'rw' }, }, }); const ttl = getModel('auth').data.ttl; assert(ttl > 890 * 1000 && ttl <= 900 * 1000, `exp-based ttl (~900s): got ${ttl}`); }); /* ── Runner ────────────────────────────────────────────────── */ (async () => { for (const { name, fn } of tests) { try { await fn(); console.log(` \u2713 ${name}`); passed++; } catch (e) { console.error(` \u2717 ${name}: ${e.message}`); failed++; } } console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); process.exitCode = failed ? 1 : 0; })();