diff --git a/stores/__tests__/auth-store-restore-outage.test.ts b/stores/__tests__/auth-store-restore-outage.test.ts new file mode 100644 index 000000000..44fecd661 --- /dev/null +++ b/stores/__tests__/auth-store-restore-outage.test.ts @@ -0,0 +1,88 @@ +import { beforeAll, beforeEach, expect, it, vi } from 'vitest'; +import { createConfig } from '@/lib/__tests__/fixtures/config'; + +// A reload during a server outage must keep a remembered account. Only a real +// rejection may evict it and clear its saved credentials. (#1167) +const connectError = vi.hoisted(() => ({ current: null as (() => Error) | null })); + +vi.mock('@/lib/jmap/client', () => { + class RequestTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Request timed out after ${timeoutMs}ms`); + this.name = 'RequestTimeoutError'; + } + } + class JMAPClient { + constructor(public serverUrl: string, public username: string, public password: string) {} + static withBearer() { throw new Error('not used'); } + async connect() { if (connectError.current) throw connectError.current(); } + getSessionUsername() { return this.username; } + getUsername() { return this.username; } + async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; } + getAuthHeader() { return 'Basic eA=='; } + onConnectionChange() {} + onRateLimit() {} + getRateLimitRemainingMs() { return 0; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + disconnect() {} + getAccountId() { return 'acct'; } + } + class RateLimitError extends Error {} + return { JMAPClient, RateLimitError, RequestTimeoutError }; +}); +vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null })); + +import { RequestTimeoutError } from '@/lib/jmap/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; + +const account = { + id: 'bob@x.test@mail.x.test', label: 'Bob', serverUrl: 'https://mail.x.test', username: 'bob@x.test', authMode: 'basic' as const, + rememberMe: true, cookieSlot: 0, displayName: 'Bob', email: 'bob@x.test', isConnected: false, hasError: false, + lastLoginAt: 1, avatarColor: '#000000', isDefault: true, +}; +const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/auth/session') && (!init?.method || init.method === 'GET')) { + return new Response(JSON.stringify({ serverUrl: account.serverUrl, username: account.username, password: 'x' }), { status: 200 }); + } + if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 }); + return new Response('{}', { status: 200 }); +}); + +function credentialsCleared() { + return fetchMock.mock.calls.some(([input, init]) => String(input).includes('/api/auth/session') && init?.method === 'DELETE'); +} + +beforeAll(() => { + vi.stubGlobal('fetch', fetchMock); +}); + +beforeEach(() => { + fetchMock.mockClear(); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null, error: null }); + useAccountStore.setState({ accounts: [account] as never, activeAccountId: account.id, defaultAccountId: account.id }); +}); + +it.each([ + ['a request timeout', () => new RequestTimeoutError(30_000)], + ['a CORS_ERROR from a failed fetch', () => new Error('CORS_ERROR')], + ['a network TypeError', () => new TypeError('Failed to fetch')], + ['a 502 from the session endpoint', () => new Error('Failed to get session: 502')], +])('keeps the account through %s', async (_label, error) => { + connectError.current = error; + await useAuthStore.getState().checkAuth(); + expect(useAccountStore.getState().getAccountById(account.id)).toMatchObject({ hasError: true, errorMessage: 'Server unreachable' }); + expect(credentialsCleared()).toBe(false); +}); + +it('signs out on a real rejection', async () => { + connectError.current = () => new Error('Failed to get session: 401'); + await useAuthStore.getState().checkAuth(); + expect(useAccountStore.getState().getAccountById(account.id)).toBeUndefined(); + await vi.waitFor(() => expect(credentialsCleared()).toBe(true)); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 5a79f6ba4..7ee0bc23a 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { JMAPClient, RateLimitError } from '@/lib/jmap/client'; +import { JMAPClient, RateLimitError, RequestTimeoutError } from '@/lib/jmap/client'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { useIdentityStore } from './identity-store'; import { setClientLookup } from './client-registry'; @@ -207,6 +207,11 @@ function isTransientAuthError(error: unknown): boolean { if (error instanceof TransientAuthError) return true; // fetch() rejects with TypeError when the network is unreachable. if (error instanceof TypeError) return true; + // A request that timed out, or a fetch that failed while the no-cors probe + // got through (connect() reports that as CORS_ERROR, which is also what a + // load balancer switching origins produces), is an outage too. (#1167) + if (error instanceof RequestTimeoutError) return true; + if (error instanceof Error && error.message === 'CORS_ERROR') return true; // JMAPClient.connect()/refreshSession() embed the HTTP status in the // message - a 5xx there is the server being down, not an auth failure. if (error instanceof Error) {