mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: recover from transient application configuration failures
Retry interrupted or invalid runtime configuration requests with bounded backoff and per-attempt timeouts. Validate the complete response before caching it, and prevent stale HTTP-cached configuration. Cover request and body timeouts, shared hook consumers, retry bounds, the server response contract, and both Lite configuration paths.
This commit is contained in:
@@ -96,9 +96,13 @@ export async function GET(request: NextRequest) {
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
},
|
||||
{
|
||||
// Branding varies by host, so any cache between us and the browser
|
||||
// must key its entry by the host headers we consulted.
|
||||
headers: { Vary: 'Host, X-Forwarded-Host' },
|
||||
// Runtime settings can change without new asset URLs. Prevent stale
|
||||
// browser/proxy responses, retaining Vary for host-specific branding.
|
||||
headers: {
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
'CDN-Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiFetch: vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(),
|
||||
fetchPolicy: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/browser-navigation', () => ({ apiFetch: mocks.apiFetch }));
|
||||
vi.mock('@/stores/policy-store', () => ({
|
||||
usePolicyStore: { getState: () => ({ fetchPolicy: mocks.fetchPolicy }) },
|
||||
}));
|
||||
|
||||
const config = createConfig();
|
||||
const success = () => Response.json(config);
|
||||
const untilAborted = (signal: AbortSignal) => new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.apiFetch.mockReset();
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_LITE', '0');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('server configuration recovery', () => {
|
||||
it('caches a successful first response without leaving a timeout', async () => {
|
||||
mocks.apiFetch.mockResolvedValueOnce(success());
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
expect(await fetchConfig()).toEqual(config);
|
||||
expect(await fetchConfig()).toEqual(config);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.fetchPolicy).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('recovers from an interrupted request, sharing retries and the successful cache', async () => {
|
||||
mocks.apiFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValueOnce(success());
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
// Observe rejection immediately so this also reports a clean regression on
|
||||
// the old one-shot loader, without an unhandled promise rejection.
|
||||
const first = fetchConfig().then(value => ({ value }), error => ({ error: error.message }));
|
||||
const second = fetchConfig().then(value => ({ value }), error => ({ error: error.message }));
|
||||
await vi.runAllTimersAsync();
|
||||
expect(await first).toEqual({ value: config });
|
||||
expect(await second).toEqual({ value: config });
|
||||
expect(await fetchConfig()).toEqual(config);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledWith('/api/config', expect.objectContaining({ cache: 'no-store' }));
|
||||
expect(mocks.fetchPolicy).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('waits between attempts instead of immediately repeating failed requests', async () => {
|
||||
mocks.apiFetch
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce(new Response('Bad gateway', { status: 502 }))
|
||||
.mockResolvedValueOnce(success());
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const pending = fetchConfig();
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1499);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(3);
|
||||
expect(await pending).toEqual(config);
|
||||
});
|
||||
|
||||
it('retries incomplete configuration without caching it or fetching policy', async () => {
|
||||
mocks.apiFetch
|
||||
.mockResolvedValueOnce(Response.json({ appName: config.appName, jmapServerUrl: config.jmapServerUrl }))
|
||||
.mockResolvedValueOnce(success());
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const pending = fetchConfig();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mocks.fetchPolicy).not.toHaveBeenCalled();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(await pending).toEqual(config);
|
||||
expect(await fetchConfig()).toEqual(config);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.fetchPolicy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recovers from wrong-typed fields and malformed nested server entries', async () => {
|
||||
mocks.apiFetch
|
||||
.mockResolvedValueOnce(Response.json({ ...config, oauthOnly: 'false' }))
|
||||
.mockResolvedValueOnce(Response.json({ ...config, jmapServers: [{ id: 'a', url: 'https://mail.example.com' }] }))
|
||||
.mockResolvedValueOnce(success());
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const pending = fetchConfig();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(await pending).toEqual(config);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('recovers when an intermediary serves HTML or an error object with HTTP 200', async () => {
|
||||
mocks.apiFetch
|
||||
.mockResolvedValueOnce(new Response('<html>Gateway error</html>'))
|
||||
.mockResolvedValueOnce(Response.json({ error: 'temporarily unavailable' }))
|
||||
.mockResolvedValueOnce(success());
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const pending = fetchConfig();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(await pending).toEqual(config);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('bounds repeated failures and permits a later fresh request', async () => {
|
||||
mocks.apiFetch.mockRejectedValue(new TypeError('Network unavailable'));
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const failed = expect(fetchConfig()).rejects.toThrow('Network unavailable');
|
||||
await vi.runAllTimersAsync();
|
||||
await failed;
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.fetchPolicy).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
mocks.apiFetch.mockResolvedValueOnce(success());
|
||||
expect(await fetchConfig()).toEqual(config);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['headers', 'body'])('times out stalled response %s and recovers with a fresh signal', async phase => {
|
||||
mocks.apiFetch.mockImplementationOnce((_url, options) => {
|
||||
const stalled = untilAborted(options!.signal!);
|
||||
if (phase === 'headers') return stalled;
|
||||
const response = new Response();
|
||||
vi.spyOn(response, 'json').mockImplementation(() => stalled);
|
||||
return Promise.resolve(response);
|
||||
}).mockResolvedValueOnce(success());
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const pending = fetchConfig();
|
||||
await vi.advanceTimersByTimeAsync(9999);
|
||||
const firstSignal = mocks.apiFetch.mock.calls[0][1]!.signal!;
|
||||
expect(firstSignal.aborted).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(await pending).toEqual(config);
|
||||
const secondSignal = mocks.apiFetch.mock.calls[1][1]!.signal!;
|
||||
expect(secondSignal).not.toBe(firstSignal);
|
||||
expect(secondSignal.aborted).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('terminates when every attempt stalls instead of leaving a permanent spinner', async () => {
|
||||
mocks.apiFetch.mockImplementation((_url, options) => untilAborted(options!.signal!));
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const failed = expect(fetchConfig()).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await vi.advanceTimersByTimeAsync(32000);
|
||||
await failed;
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.apiFetch.mock.calls.every(([, options]) => options!.signal!.aborted)).toBe(true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps mounted consumers loading through recovery when another consumer unmounts', async () => {
|
||||
mocks.apiFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValueOnce(success());
|
||||
const { useConfig } = await import('../use-config');
|
||||
const first = renderHook(() => useConfig());
|
||||
const second = renderHook(() => useConfig());
|
||||
await act(() => vi.advanceTimersByTimeAsync(499));
|
||||
expect(second.result.current).toMatchObject({ isLoading: true, error: null });
|
||||
first.unmount();
|
||||
await act(() => vi.advanceTimersByTimeAsync(1));
|
||||
expect(second.result.current).toEqual({ ...config, isLoading: false, error: null });
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows the error state only after all attempts fail', async () => {
|
||||
mocks.apiFetch.mockRejectedValue(new TypeError('Network unavailable'));
|
||||
const { useConfig } = await import('../use-config');
|
||||
const { result } = renderHook(() => useConfig());
|
||||
await act(() => vi.advanceTimersByTimeAsync(1999));
|
||||
expect(result.current).toMatchObject({ isLoading: true, error: null });
|
||||
await act(() => vi.advanceTimersByTimeAsync(1));
|
||||
expect(result.current).toMatchObject({ isLoading: false, error: 'Network unavailable' });
|
||||
expect(mocks.apiFetch).toHaveBeenCalledTimes(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lite configuration compatibility', () => {
|
||||
it('preserves static defaults when its optional config is missing', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_LITE', '1');
|
||||
vi.stubEnv('NEXT_PUBLIC_LITE_TARGET', 'static');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mocks.apiFetch.mockResolvedValueOnce(new Response(null, { status: 404 }));
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const data = await fetchConfig();
|
||||
expect(data.appName).toBe('Webmail');
|
||||
expect(data.allowCustomJmapEndpoint).toBe(true);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledExactlyOnceWith('/config.json', { cache: 'no-store' });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps Stalwart config build-versioned and accepts partial deployer settings', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_LITE', '1');
|
||||
vi.stubEnv('NEXT_PUBLIC_LITE_TARGET', 'stalwart');
|
||||
vi.stubEnv('NEXT_PUBLIC_LITE_BUILD_ID', 'test-build');
|
||||
mocks.apiFetch.mockResolvedValueOnce(Response.json({ appName: 'Stalwart Mail' }));
|
||||
const { fetchConfig } = await import('../use-config');
|
||||
const data = await fetchConfig();
|
||||
expect(data.appName).toBe('Stalwart Mail');
|
||||
expect(data.jmapServerUrl).toBe(window.location.origin);
|
||||
expect(data.allowCustomJmapEndpoint).toBe(false);
|
||||
expect(mocks.apiFetch).toHaveBeenCalledExactlyOnceWith('/config.json?v=test-build', undefined);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
+29
-4
@@ -6,6 +6,7 @@ import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/lib/lite';
|
||||
import { applyLiteConfig, liteStalwartDefaults } from '@/lib/lite-config';
|
||||
import { isConfigData } from '@/lib/config-validation';
|
||||
|
||||
export interface ConfigData {
|
||||
appName: string;
|
||||
@@ -72,12 +73,36 @@ async function fetchLiteConfig(): Promise<ConfigData> {
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG_ATTEMPT_DELAYS_MS = [0, 500, 1500] as const;
|
||||
const CONFIG_TIMEOUT_MS = 10000;
|
||||
|
||||
async function fetchServerConfig(): Promise<ConfigData> {
|
||||
const res = await apiFetch('/api/config');
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch config');
|
||||
let lastError: unknown;
|
||||
for (const delay of CONFIG_ATTEMPT_DELAYS_MS) {
|
||||
if (delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
// Keep the timeout active while reading the body, not only the headers.
|
||||
const timeout = setTimeout(() => controller.abort(), CONFIG_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await apiFetch('/api/config', {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to fetch config (${response.status})`);
|
||||
const data: unknown = await response.json();
|
||||
if (!isConfigData(data)) {
|
||||
throw new Error('Invalid application configuration');
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
return res.json();
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/** Test hook: forget the cached config so the next fetch hits the network again. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isConfigData } from '@/lib/config-validation';
|
||||
|
||||
// The route consults admin-dashboard overrides (ADMIN_CONFIG_DIR, default
|
||||
// data/admin) before env vars. Point it at an empty temp dir so local admin
|
||||
@@ -13,7 +14,10 @@ process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-config-route-
|
||||
// Mock NextResponse before importing the route
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown) => ({ json: async () => data }),
|
||||
json: (data: unknown, init?: { headers?: HeadersInit }) => ({
|
||||
json: async () => data,
|
||||
headers: new Headers(init?.headers),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -71,9 +75,19 @@ describe('config API route', () => {
|
||||
// Re-import to pick up env changes
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
const response = await GET(mockRequest(headers) as Parameters<typeof GET>[0]);
|
||||
return response.json();
|
||||
const config = await response.json();
|
||||
expect(isConfigData(config)).toBe(true);
|
||||
return config;
|
||||
}
|
||||
|
||||
it('prevents caching host-specific runtime configuration', async () => {
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
const response = await GET(mockRequest() as Parameters<typeof GET>[0]);
|
||||
expect(response.headers.get('Cache-Control')).toContain('no-store');
|
||||
expect(response.headers.get('CDN-Cache-Control')).toBe('no-store');
|
||||
expect(response.headers.get('Vary')).toBe('Host, X-Forwarded-Host');
|
||||
});
|
||||
|
||||
it('should return defaults when no env vars are set', async () => {
|
||||
const config = await getConfig();
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isConfigData } from '@/lib/config-validation';
|
||||
import { createConfig } from './fixtures/config';
|
||||
|
||||
const server = {
|
||||
id: 'primary',
|
||||
label: 'Primary mail server',
|
||||
url: 'https://mail.example.com',
|
||||
domains: ['example.com'],
|
||||
};
|
||||
|
||||
describe('server configuration validation', () => {
|
||||
it('accepts a complete response without changing its values', () => {
|
||||
const config = createConfig();
|
||||
const before = structuredClone(config);
|
||||
|
||||
expect(isConfigData(config)).toBe(true);
|
||||
expect(config).toEqual(before);
|
||||
});
|
||||
|
||||
it('preserves empty strings, disabled flags and an empty server list', () => {
|
||||
const config = Object.fromEntries(Object.entries(createConfig()).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'string' ? '' : typeof value === 'boolean' ? false : [],
|
||||
]));
|
||||
|
||||
expect(isConfigData(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a server list without a default URL and preserves OAuth and unknown fields', () => {
|
||||
const config = {
|
||||
...createConfig({ jmapServerUrl: '', jmapServerAutoPickByDomain: true, oauthEnabled: true, oauthOnly: true }),
|
||||
jmapServers: [
|
||||
server,
|
||||
{ ...server, id: 'secondary', domains: [], oauth: { clientId: 'webmail', issuerUrl: 'https://sso.example.com' }, futureServerOption: true },
|
||||
],
|
||||
futureConfigOption: { enabled: true },
|
||||
};
|
||||
const before = structuredClone(config);
|
||||
|
||||
expect(isConfigData(config)).toBe(true);
|
||||
expect(config).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ clientId: 'webmail' },
|
||||
{ issuerUrl: 'https://sso.example.com' },
|
||||
{ clientId: '', issuerUrl: '' },
|
||||
])('accepts optional public OAuth fields: %j', (oauth) => {
|
||||
expect(isConfigData(createConfig({ jmapServers: [{ ...server, oauth }] }))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([null, undefined, false, 1, 'config', [], {}].map((value) => ({ value })))('rejects a non-configuration response: $value', ({ value }) => {
|
||||
expect(isConfigData(value)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a partial response containing only the app name and server URL', () => {
|
||||
expect(isConfigData({ appName: 'Example Mail', jmapServerUrl: 'https://mail.example.com' })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(Object.keys(createConfig()))('rejects a response missing required field %s', (key) => {
|
||||
const config: Record<string, unknown> = { ...createConfig() };
|
||||
delete config[key];
|
||||
|
||||
expect(isConfigData(config)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['appName', 42],
|
||||
['jmapServerUrl', null],
|
||||
['oauthEnabled', 'false'],
|
||||
['oauthOnly', 0],
|
||||
['oauthClientId', false],
|
||||
['loginLogoLightUrl', {}],
|
||||
['loginShowHeading', null],
|
||||
['allowCustomJmapEndpoint', 'true'],
|
||||
['parentOrigin', ['https://parent.example.com']],
|
||||
])('rejects an incorrectly typed %s', (key, value) => {
|
||||
expect(isConfigData({ ...createConfig(), [key as string]: value })).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
'servers',
|
||||
[null],
|
||||
['primary'],
|
||||
[{ ...server, id: 1 }],
|
||||
[{ ...server, label: false }],
|
||||
[{ ...server, url: {} }],
|
||||
[{ ...server, domains: 'example.com' }],
|
||||
[{ ...server, domains: [1] }],
|
||||
[{ ...server, oauth: null }],
|
||||
[{ ...server, oauth: [] }],
|
||||
[{ ...server, oauth: 'oauth' }],
|
||||
[{ ...server, oauth: { clientId: false } }],
|
||||
[{ ...server, oauth: { issuerUrl: 42 } }],
|
||||
].map((jmapServers) => ({ jmapServers })))('rejects malformed server entries: $jmapServers', ({ jmapServers }) => {
|
||||
expect(isConfigData({ ...createConfig(), jmapServers })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['id', 'label', 'url', 'domains'])('rejects a server entry missing %s', (key) => {
|
||||
const entry: Record<string, unknown> = { ...server };
|
||||
delete entry[key];
|
||||
|
||||
expect(isConfigData({ ...createConfig(), jmapServers: [entry] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ConfigData } from '@/hooks/use-config';
|
||||
|
||||
/** A complete server response; callers override only the behavior under test. */
|
||||
export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
|
||||
return {
|
||||
appName: 'Example Mail',
|
||||
jmapServerUrl: 'https://mail.example.com',
|
||||
oauthEnabled: false,
|
||||
oauthOnly: false,
|
||||
oauthClientId: '',
|
||||
oauthIssuerUrl: '',
|
||||
oauthScopes: '',
|
||||
rememberMeEnabled: false,
|
||||
settingsSyncEnabled: false,
|
||||
stalwartFeaturesEnabled: true,
|
||||
stalwartJmapPassthroughEnabled: true,
|
||||
devMode: false,
|
||||
faviconUrl: '/branding/Bulwark_Favicon.svg',
|
||||
appLogoLightUrl: '',
|
||||
appLogoDarkUrl: '',
|
||||
loginLogoLightUrl: '/branding/Bulwark_Logo_Color.svg',
|
||||
loginLogoDarkUrl: '/branding/Bulwark_Logo_White.svg',
|
||||
loginCompanyName: '',
|
||||
loginImprintUrl: '',
|
||||
loginPrivacyPolicyUrl: '',
|
||||
loginWebsiteUrl: '',
|
||||
loginLogoMaxHeight: '',
|
||||
loginLogoMaxWidth: '',
|
||||
loginShowHeading: true,
|
||||
loginShowSubtitle: true,
|
||||
loginShowTotp: true,
|
||||
loginShowVersion: true,
|
||||
demoMode: false,
|
||||
autoSsoEnabled: false,
|
||||
allowCustomJmapEndpoint: false,
|
||||
jmapServers: [],
|
||||
jmapServerAutoPickByDomain: false,
|
||||
embeddedMode: false,
|
||||
parentOrigin: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ConfigData } from '@/hooks/use-config';
|
||||
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
type Validator<T> = (value: unknown) => value is T;
|
||||
type Validators<T> = { [Key in keyof T]-?: Validator<T[Key]> };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const isString: Validator<string> = (value): value is string => typeof value === 'string';
|
||||
const isBoolean: Validator<boolean> = (value): value is boolean => typeof value === 'boolean';
|
||||
|
||||
function isJmapServers(value: unknown): value is PublicJmapServerEntry[] {
|
||||
return Array.isArray(value) && value.every((entry: unknown) => {
|
||||
if (!isRecord(entry)) return false;
|
||||
if (!isString(entry.id) || !isString(entry.label) || !isString(entry.url)) return false;
|
||||
if (!Array.isArray(entry.domains) || !entry.domains.every(isString)) return false;
|
||||
if (entry.oauth === undefined) return true;
|
||||
return isRecord(entry.oauth)
|
||||
&& (entry.oauth.clientId === undefined || isString(entry.oauth.clientId))
|
||||
&& (entry.oauth.issuerUrl === undefined || isString(entry.oauth.issuerUrl));
|
||||
});
|
||||
}
|
||||
|
||||
// Keep this exhaustive: a new required config field must be validated before
|
||||
// a response can replace the hook's complete initial state and enter its cache.
|
||||
const configValidators = {
|
||||
appName: isString,
|
||||
jmapServerUrl: isString,
|
||||
oauthEnabled: isBoolean,
|
||||
oauthOnly: isBoolean,
|
||||
oauthClientId: isString,
|
||||
oauthIssuerUrl: isString,
|
||||
oauthScopes: isString,
|
||||
rememberMeEnabled: isBoolean,
|
||||
settingsSyncEnabled: isBoolean,
|
||||
stalwartFeaturesEnabled: isBoolean,
|
||||
stalwartJmapPassthroughEnabled: isBoolean,
|
||||
devMode: isBoolean,
|
||||
faviconUrl: isString,
|
||||
appLogoLightUrl: isString,
|
||||
appLogoDarkUrl: isString,
|
||||
loginLogoLightUrl: isString,
|
||||
loginLogoDarkUrl: isString,
|
||||
loginCompanyName: isString,
|
||||
loginImprintUrl: isString,
|
||||
loginPrivacyPolicyUrl: isString,
|
||||
loginWebsiteUrl: isString,
|
||||
loginLogoMaxHeight: isString,
|
||||
loginLogoMaxWidth: isString,
|
||||
loginShowHeading: isBoolean,
|
||||
loginShowSubtitle: isBoolean,
|
||||
loginShowTotp: isBoolean,
|
||||
loginShowVersion: isBoolean,
|
||||
demoMode: isBoolean,
|
||||
autoSsoEnabled: isBoolean,
|
||||
allowCustomJmapEndpoint: isBoolean,
|
||||
jmapServers: isJmapServers,
|
||||
jmapServerAutoPickByDomain: isBoolean,
|
||||
embeddedMode: isBoolean,
|
||||
parentOrigin: isString,
|
||||
} satisfies Validators<ConfigData>;
|
||||
|
||||
/** Validate the server's complete response without rewriting configured values. */
|
||||
export function isConfigData(value: unknown): value is ConfigData {
|
||||
return isRecord(value)
|
||||
&& Object.entries(configValidators).every(([key, validate]) => validate(value[key]));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeAll, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
// The Account Security store was never reset on an account switch, and the
|
||||
// settings page only fetches while isStalwart is null. After switching from
|
||||
@@ -56,7 +57,7 @@ beforeAll(() => {
|
||||
passthrough.push({ slot: h.get('X-JMAP-Cookie-Slot'), body: JSON.parse(String(init?.body)) });
|
||||
return new Response(JSON.stringify({ methodResponses: [['x:AppPassword/set', { destroyed: ['a'] }, '0'], ['x:AccountPassword/get', { list: [{}] }, '0'], ['x:AppPassword/query', { ids: [] }, '1'], ['x:ApiKey/query', { ids: [] }, '2']] }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify({ settingsSyncEnabled: false }), { status: 200 });
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeAll, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
// Signing out of the active account when the next registered account has no
|
||||
// live client (its restore failed at boot) takes the full-logout path. That
|
||||
@@ -62,7 +63,7 @@ beforeAll(() => {
|
||||
}
|
||||
}
|
||||
if (url.includes('/api/auth/token') && method === 'DELETE') { deletes.push(url); return new Response('{}', { status: 200 }); }
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify({ settingsSyncEnabled: false }), { status: 200 });
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeAll, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
// Signing out of one of two accounts switched to the remaining one but never
|
||||
// turned settings sync back on, so later settings edits were not pushed and
|
||||
@@ -51,7 +52,7 @@ beforeAll(() => {
|
||||
const a = [A, B][Number(m[1])];
|
||||
return new Response(JSON.stringify({ serverUrl: a.serverUrl, username: a.username, password: 'pw' }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify({ settingsSyncEnabled: true }), { status: 200 });
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig({ settingsSyncEnabled: true })), { status: 200 });
|
||||
if (url.includes('/api/settings') && init?.method === 'POST') {
|
||||
posts.push(JSON.parse(String(init.body)).username);
|
||||
return new Response('{}', { status: 200 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeAll, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
// Two overlapping switchAccount() calls (click account B, then C before the
|
||||
// first switch lands) used to snapshot B's live stores under account A's
|
||||
@@ -61,7 +62,7 @@ beforeAll(() => {
|
||||
const a = [A, B, C][Number(m[1])];
|
||||
return new Response(JSON.stringify({ serverUrl: a.serverUrl, username: a.username, password: 'pw' }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify({ settingsSyncEnabled: false }), { status: 200 });
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user