ui: per-container #comp lifecycle, exp-claim auth refresh TTL
- 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
This commit is contained in:
@@ -185,6 +185,118 @@ test('refresh action rotates tokens; new session_id wins, omitted fields fall ba
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user