Changeset 0.10.2 (#58)
This commit is contained in:
243
src/js/__tests__/auth-resilience.test.js
Normal file
243
src/js/__tests__/auth-resilience.test.js
Normal file
@@ -0,0 +1,243 @@
|
||||
// ==========================================
|
||||
// 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.
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user