diff --git a/app/api/config/route.ts b/app/api/config/route.ts index f92f07507..151e0370a 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -96,9 +96,13 @@ export async function GET(request: NextRequest) { parentOrigin: configManager.get('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', + }, }, ); } diff --git a/hooks/__tests__/use-config-recovery.test.ts b/hooks/__tests__/use-config-recovery.test.ts new file mode 100644 index 000000000..05ed945ed --- /dev/null +++ b/hooks/__tests__/use-config-recovery.test.ts @@ -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>(), + 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((_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('Gateway error')) + .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); + }); +}); diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 4958b77a6..da37c653b 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -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 { } } +const CONFIG_ATTEMPT_DELAYS_MS = [0, 500, 1500] as const; +const CONFIG_TIMEOUT_MS = 10000; + async function fetchServerConfig(): Promise { - 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. */ diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 9d4cb48b3..7b9102fbf 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -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[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[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(); diff --git a/lib/__tests__/config-validation.test.ts b/lib/__tests__/config-validation.test.ts new file mode 100644 index 000000000..b2737ee7e --- /dev/null +++ b/lib/__tests__/config-validation.test.ts @@ -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 = { ...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 = { ...server }; + delete entry[key]; + + expect(isConfigData({ ...createConfig(), jmapServers: [entry] })).toBe(false); + }); +}); diff --git a/lib/__tests__/fixtures/config.ts b/lib/__tests__/fixtures/config.ts new file mode 100644 index 000000000..a5e09e5f2 --- /dev/null +++ b/lib/__tests__/fixtures/config.ts @@ -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 { + 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, + }; +} diff --git a/lib/config-validation.ts b/lib/config-validation.ts new file mode 100644 index 000000000..6aeb67897 --- /dev/null +++ b/lib/config-validation.ts @@ -0,0 +1,69 @@ +import type { ConfigData } from '@/hooks/use-config'; +import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers'; + +type Validator = (value: unknown) => value is T; +type Validators = { [Key in keyof T]-?: Validator }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +const isString: Validator = (value): value is string => typeof value === 'string'; +const isBoolean: Validator = (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; + +/** 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])); +} diff --git a/stores/__tests__/account-security-switch.test.ts b/stores/__tests__/account-security-switch.test.ts index 0d65dcdbc..f71e1bda1 100644 --- a/stores/__tests__/account-security-switch.test.ts +++ b/stores/__tests__/account-security-switch.test.ts @@ -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 }); })); }); diff --git a/stores/__tests__/auth-store-logout-orphan.test.ts b/stores/__tests__/auth-store-logout-orphan.test.ts index b502a39a1..d58573a74 100644 --- a/stores/__tests__/auth-store-logout-orphan.test.ts +++ b/stores/__tests__/auth-store-logout-orphan.test.ts @@ -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(() => {}); diff --git a/stores/__tests__/auth-store-logout-sync.test.ts b/stores/__tests__/auth-store-logout-sync.test.ts index 022ddcd98..39026436b 100644 --- a/stores/__tests__/auth-store-logout-sync.test.ts +++ b/stores/__tests__/auth-store-logout-sync.test.ts @@ -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 }); diff --git a/stores/__tests__/auth-store-switch-race.test.ts b/stores/__tests__/auth-store-switch-race.test.ts index 3099aaba7..1ad01d8a6 100644 --- a/stores/__tests__/auth-store-switch-race.test.ts +++ b/stores/__tests__/auth-store-switch-race.test.ts @@ -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 }); })); });