// ========================================== // Auth Resilience Tests // ========================================== // Validates that auth failure during startup // results in a redirect to login, NOT a broken // main UI with 401 errors everywhere. // // Bug: Profile returned 200 (token on edge of // expiry), startApp() proceeded, then every // subsequent call 401'd. User saw broken UI // instead of login screen. // // Run: node --test src/js/__tests__/auth-resilience.test.js // ========================================== const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); // ── Auth state model ──────────────────────── describe('Auth state transitions', () => { // Simulates the API client's auth lifecycle function makeAuthState() { return { accessToken: null, refreshToken: null, get isAuthed() { return !!this.accessToken; }, clearTokens() { this.accessToken = null; this.refreshToken = null; }, setTokens(access, refresh) { this.accessToken = access; this.refreshToken = refresh; }, }; } it('isAuthed reflects token presence', () => { const auth = makeAuthState(); assert.equal(auth.isAuthed, false); auth.setTokens('tok_access', 'tok_refresh'); assert.equal(auth.isAuthed, true); }); it('clearTokens makes isAuthed false', () => { const auth = makeAuthState(); auth.setTokens('tok_access', 'tok_refresh'); assert.equal(auth.isAuthed, true); auth.clearTokens(); assert.equal(auth.isAuthed, false); }); it('expired token string is still truthy (the edge case)', () => { const auth = makeAuthState(); // An expired JWT is still a non-empty string — isAuthed returns true // This is BY DESIGN: the server validates, not the client const expiredJWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.fake'; auth.setTokens(expiredJWT, 'tok_refresh'); assert.equal(auth.isAuthed, true, 'expired JWT is still truthy'); }); }); // ── Startup auth guard ────────────────────── describe('startApp auth guard', () => { // This models the critical startup sequence: // init(): // if (isAuthed) → getProfile() → if ok → startApp() // startApp(): // loadSettings() → loadChats() → fetchModels() // *** AUTH GUARD CHECK HERE *** // if (!isAuthed) → showSplash, return // else → render UI it('proceeds when auth is valid throughout startup', () => { let splashShown = false; let uiRendered = false; const auth = { isAuthed: true }; // Simulate: loadChats succeeds, auth stays valid const loadChats = () => { /* no 401 */ }; const fetchModels = () => { /* no 401 */ }; // startApp logic loadChats(); fetchModels(); if (!auth.isAuthed) { splashShown = true; } else { uiRendered = true; } assert.equal(uiRendered, true, 'UI should render when auth is valid'); assert.equal(splashShown, false); }); it('returns to login when auth is lost during startup', () => { let splashShown = false; let uiRendered = false; const auth = { accessToken: 'tok', get isAuthed() { return !!this.accessToken; }, clearTokens() { this.accessToken = null; }, }; // Simulate: loadChats gets 401, refresh fails, clearTokens called const loadChats = () => { // _authed → 401 → refresh() → fails → clearTokens() auth.clearTokens(); }; const fetchModels = () => { /* also 401 but tokens already cleared */ }; loadChats(); fetchModels(); // THE FIX: guard check after critical loads if (!auth.isAuthed) { splashShown = true; } else { uiRendered = true; } assert.equal(splashShown, true, 'must return to login when auth lost'); assert.equal(uiRendered, false, 'must NOT render UI when auth lost'); }); it('detects auth loss even when only one call triggers it', () => { const auth = { accessToken: 'tok', get isAuthed() { return !!this.accessToken; }, clearTokens() { this.accessToken = null; }, }; let splashShown = false; // loadSettings succeeds (token barely valid) // loadChats triggers 401 → clearTokens const loadSettings = () => { /* ok */ }; const loadChats = () => { auth.clearTokens(); }; const fetchModels = () => { /* tokens already gone, silently fails */ }; loadSettings(); loadChats(); fetchModels(); if (!auth.isAuthed) splashShown = true; assert.equal(splashShown, true, 'single 401 during startup must trigger login redirect'); }); }); // ── 401 retry + refresh behavior ───────────── describe('401 retry with refresh', () => { it('successful refresh preserves auth', () => { const auth = { accessToken: 'expired', refreshToken: 'valid_refresh', get isAuthed() { return !!this.accessToken; }, clearTokens() { this.accessToken = null; this.refreshToken = null; }, refresh() { // Simulate successful refresh this.accessToken = 'new_valid_token'; return true; }, }; // _authed: first call 401, refresh succeeds, retry succeeds const result401 = { status: 401 }; if (result401.status === 401 && auth.refreshToken) { auth.refresh(); } assert.equal(auth.isAuthed, true, 'auth preserved after successful refresh'); assert.equal(auth.accessToken, 'new_valid_token'); }); it('failed refresh clears auth', () => { const auth = { accessToken: 'expired', refreshToken: 'also_expired', get isAuthed() { return !!this.accessToken; }, clearTokens() { this.accessToken = null; this.refreshToken = null; }, refresh() { // Simulate failed refresh (401 on refresh endpoint) this.clearTokens(); return false; }, }; const result401 = { status: 401 }; if (result401.status === 401 && auth.refreshToken) { auth.refresh(); } assert.equal(auth.isAuthed, false, 'auth cleared after failed refresh'); }); }); // ── Init flow: profile check ───────────────── describe('init() profile gate', () => { it('clears tokens when profile returns 401', () => { const auth = { accessToken: 'expired', refreshToken: 'expired_too', get isAuthed() { return !!this.accessToken; }, clearTokens() { this.accessToken = null; this.refreshToken = null; }, }; // Simulate getProfile throwing (as _authed does after failed refresh) const profileFailed = true; if (profileFailed) auth.clearTokens(); assert.equal(auth.isAuthed, false, 'tokens cleared on profile failure'); }); it('profile success + subsequent 401 = the edge case bug', () => { // This is the exact scenario from the bug report: // 1. Profile returns 200 (token barely valid, 5ms) // 2. init() proceeds to startApp() // 3. loadChats() returns 401 (token expired in the 200ms gap) // 4. Without the guard, user sees broken UI const auth = { accessToken: 'barely_valid', get isAuthed() { return !!this.accessToken; }, clearTokens() { this.accessToken = null; }, }; // Profile succeeds (token still valid) const profileOk = true; assert.equal(profileOk, true); assert.equal(auth.isAuthed, true, 'auth valid after profile'); // startApp: loadChats fails, clears tokens auth.clearTokens(); // THE GUARD: must catch this assert.equal(auth.isAuthed, false, 'auth gone after loadChats 401'); // Without guard: UI renders. With guard: splash shown. }); });