2b7fe1f485
- hoover: #comp registry + expanded-content cache now per render container; committing one root no longer unmounts/remounts components owned by another root (infinite load loop on pages whose load() re-mutates reactive state) - auth_model: refresh timer scheduled from the token's remaining exp claim (unverified decode, mirrors lib/auth.py); falls back to the configured TTL for non-JWT/malformed/already-expired tokens - docs: hoover.md documents both behaviors - tests: exp-claim TTL cases in test-auth-model.js; new test-render-lifecycle.js regression suite
316 lines
12 KiB
JavaScript
316 lines
12 KiB
JavaScript
/**
|
|
* 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;
|
|
})();
|