Fix: keep the session when the auth server is briefly unreachable
Any transient failure used to end the session: the token route deleted the refresh cookies on every non-OK answer from the OAuth endpoint, refreshAccessToken logged out on any non-OK status or network error, and the startup restore evicted the account and deleted its session cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop waking before the network is back all kicked the user out despite "stay signed in". Failures are now classified. Only a definitive rejection (400/401/403 from the OAuth endpoint, 401 from the token route) tears the session down and deletes cookies, exactly as before. Network errors and 5xx keep the session: the token refresh re-arms itself and retries every ~30 seconds until the server is back, and the startup restore keeps the account, marked unreachable - the same treatment the rate-limit carve-out (#104) already applies. Token validity stays entirely server-enforced: the first definitive 401 after an outage still logs out as before.
This commit is contained in:
@@ -55,7 +55,7 @@ describe('auth-store logout redirects', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true });
|
||||
});
|
||||
|
||||
it('marks session expiry, preserves the current path, and redirects to login on refresh failure', async () => {
|
||||
it('marks session expiry, preserves the current path, and redirects to login when the refresh is rejected (401)', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
||||
@@ -63,7 +63,7 @@ describe('auth-store logout redirects', () => {
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (url === '/api/auth/token?slot=0' && method === 'PUT') {
|
||||
return { ok: false, json: async () => ({}) };
|
||||
return { ok: false, status: 401, json: async () => ({}) };
|
||||
}
|
||||
|
||||
if (url === '/api/auth/token?slot=0' && method === 'DELETE') {
|
||||
@@ -94,4 +94,74 @@ describe('auth-store logout redirects', () => {
|
||||
expect(sessionStorage.getItem('redirect_after_login')).toBe('/en/calendar?view=day');
|
||||
expect(replaceSpy).toHaveBeenCalledWith('/en/login');
|
||||
});
|
||||
|
||||
it('keeps the session and schedules a retry when the token endpoint is unavailable (5xx)', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (url === '/api/auth/token?slot=0' && method === 'PUT') {
|
||||
return { ok: false, status: 503, json: async () => ({}) };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
||||
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
authMode: 'oauth',
|
||||
activeAccountId: null,
|
||||
});
|
||||
|
||||
const token = await useAuthStore.getState().refreshAccessToken();
|
||||
|
||||
expect(token).toBeNull();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(sessionStorage.getItem('session_expired')).toBeNull();
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
|
||||
// A retry is armed: advancing past the ~30 s window fires a second PUT.
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
const refreshPuts = fetchMock.mock.calls.filter(
|
||||
([input, init]) => String(input) === '/api/auth/token?slot=0' && init?.method === 'PUT',
|
||||
);
|
||||
expect(refreshPuts.length).toBe(2);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the session when the refresh request fails with a network error', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (url === '/api/auth/token?slot=0' && method === 'PUT') {
|
||||
throw new TypeError('Failed to fetch');
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
||||
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
authMode: 'oauth',
|
||||
activeAccountId: null,
|
||||
});
|
||||
|
||||
const token = await useAuthStore.getState().refreshAccessToken();
|
||||
|
||||
expect(token).toBeNull();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(sessionStorage.getItem('session_expired')).toBeNull();
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user