Changeset 0.10.2 (#58)

This commit is contained in:
2026-02-24 20:29:08 +00:00
parent 13772ebd6c
commit 4061e4a145
20 changed files with 1223 additions and 41 deletions

View 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.
});
});