Merge branches 'main' and 'main' of https://github.com/bulwarkmail/webmail

This commit is contained in:
Linus Rath
2026-10-11 19:09:59 +02:00
2 changed files with 94 additions and 1 deletions
@@ -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));
});
+6 -1
View File
@@ -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) {