mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Merge branches 'main' and 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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));
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user