244 lines
8.3 KiB
JavaScript
244 lines
8.3 KiB
JavaScript
// ==========================================
|
|
// 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.
|
|
});
|
|
});
|