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:
Lucius7nya
2026-09-27 15:41:21 +08:00
parent 5ce8c89c51
commit d923b92034
11 changed files with 501 additions and 13 deletions
+7 -3
View File
@@ -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',
},
},
);
}
+221
View File
@@ -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
View File
@@ -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. */
+16 -2
View File
@@ -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();
+109
View File
@@ -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);
});
});
+42
View File
@@ -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,
};
}
+69
View File
@@ -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 });
}));
});