Files
webmail/stores/__tests__/auth-store-logout.test.ts
T

292 lines
11 KiB
TypeScript

import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import * as browserNavigation from '@/lib/browser-navigation';
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
describe('auth-store logout redirects', () => {
beforeEach(() => {
vi.restoreAllMocks();
sessionStorage.clear();
localStorage.clear();
window.history.pushState({}, '', '/en');
useAccountStore.setState({
accounts: [],
activeAccountId: null,
defaultAccountId: null,
});
useAuthStore.setState({
isAuthenticated: false,
isLoading: false,
error: null,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
activeAccountId: null,
});
});
afterEach(() => {
vi.useRealTimers();
});
it('redirects full logout to the locale login page', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal('fetch', fetchMock);
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
window.history.pushState({}, '', '/fr/calendar');
useAuthStore.setState({ isAuthenticated: true, authMode: 'basic' });
await useAuthStore.getState().logout();
expect(replaceSpy).toHaveBeenCalledWith('/fr/login');
expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true });
});
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) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (url === '/api/auth/token?slot=0&force=true' && method === 'PUT') {
return { ok: false, status: 401, json: async () => ({}) };
}
if (url === '/api/auth/token?slot=0' && method === 'DELETE') {
return { ok: true, json: async () => ({}) };
}
if (url === '/api/auth/session?slot=0' && method === 'DELETE') {
return { ok: true, json: async () => ({}) };
}
throw new Error(`Unexpected fetch call: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
window.history.pushState({}, '', '/en/calendar?view=day');
useAuthStore.setState({
isAuthenticated: true,
authMode: 'oauth',
activeAccountId: null,
});
await useAuthStore.getState().refreshAccessToken();
await vi.runAllTimersAsync();
expect(sessionStorage.getItem('session_expired')).toBe('true');
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&force=true' && 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();
const countPuts = () => fetchMock.mock.calls.filter(
([input, init]) => String(input) === '/api/auth/token?slot=0&force=true' && init?.method === 'PUT',
).length;
// A retry is armed: advancing past the ~30 s window fires a second PUT.
await vi.advanceTimersByTimeAsync(31_000);
expect(countPuts()).toBe(2);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
// Backoff: after the second failure the next retry waits ~60 s, not 30.
await vi.advanceTimersByTimeAsync(31_000);
expect(countPuts()).toBe(2);
await vi.advanceTimersByTimeAsync(30_000);
expect(countPuts()).toBe(3);
});
it('stops retrying when the user signs out during the outage (#588)', async () => {
vi.useFakeTimers();
let resolveInFlight: ((value: { ok: boolean; status: number; json: () => Promise<object> }) => void) | undefined;
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&force=true' && method === 'PUT') {
return new Promise((resolve) => { resolveInFlight = resolve; });
}
if (method === 'DELETE') {
return { ok: true, json: async () => ({}) };
}
throw new Error(`Unexpected fetch call: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
useAuthStore.setState({
isAuthenticated: true,
authMode: 'oauth',
activeAccountId: null,
});
// Refresh goes in flight, then the user signs out before it settles.
// Sign-out gives the renewal a few seconds, then cancels it (#905).
const pending = useAuthStore.getState().refreshAccessToken();
const signedOut = useAuthStore.getState().logout();
await vi.advanceTimersByTimeAsync(3_000);
await signedOut;
resolveInFlight!({ ok: false, status: 503, json: async () => ({}) });
await pending;
// The failure lands after the sign-out - no retry may be re-armed.
const countPuts = () => fetchMock.mock.calls.filter(
([input, init]) => String(input) === '/api/auth/token?slot=0&force=true' && init?.method === 'PUT',
).length;
expect(countPuts()).toBe(1);
await vi.advanceTimersByTimeAsync(600_000);
expect(countPuts()).toBe(1);
});
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&force=true' && 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();
});
it('signs out after five consecutive Bulwark-side refresh failures (500) instead of retrying forever (#972)', 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&force=true' && method === 'PUT') {
return { ok: false, status: 500, json: async () => ({}) };
}
if ((url === '/api/auth/token?slot=0' || url === '/api/auth/session?slot=0') && method === 'DELETE') {
return { ok: true, json: async () => ({}) };
}
throw new Error(`Unexpected fetch call: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
window.history.pushState({}, '', '/en/mail');
useAuthStore.setState({ isAuthenticated: true, authMode: 'oauth', activeAccountId: null });
await useAuthStore.getState().refreshAccessToken();
// Each failed attempt arms one retry; the fifth 500 ends the session, so
// running all timers drains the ladder without looping forever.
await vi.runAllTimersAsync();
const puts = fetchMock.mock.calls.filter(
([input, init]) => String(input) === '/api/auth/token?slot=0&force=true' && init?.method === 'PUT',
).length;
expect(puts).toBe(5);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(sessionStorage.getItem('session_expired')).toBe('true');
expect(replaceSpy).toHaveBeenCalledWith('/en/login');
});
it('keeps retrying an upstream outage (503) beyond five failures without evicting the account (#972)', 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&force=true' && 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 });
await useAuthStore.getState().refreshAccessToken();
// The backoff ladder tops out at 360 s (armed 60 s early): five more
// windows are enough for at least five failures in a row.
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(301_000);
}
const puts = fetchMock.mock.calls.filter(
([input, init]) => String(input) === '/api/auth/token?slot=0&force=true' && init?.method === 'PUT',
).length;
expect(puts).toBeGreaterThanOrEqual(5);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(sessionStorage.getItem('session_expired')).toBeNull();
expect(replaceSpy).not.toHaveBeenCalled();
// A further retry is still armed.
expect(vi.getTimerCount()).toBeGreaterThan(0);
});
});