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:
2026-09-03 17:25:22 +00:00
parent fc478a016e
commit 2b7fe1f485
6 changed files with 466 additions and 33 deletions
+112
View File
@@ -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 () => {