Files
webmail/lib/__tests__/settings-shared-store.test.ts
T

170 lines
6.3 KiB
TypeScript

// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { setStoreForTests } from '@/lib/shared-store';
import { resetLocalStoreForTests } from '@/lib/shared-store/local';
import { SETTINGS_COLLECTION, settingsId } from '@/lib/settings-sync';
import { separateDiskStore, type SeparateDiskStore } from './helpers/separate-disk-store';
// Synced settings with a shared store (#1066): every replica reads and
// writes the one store, never its own disk.
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => 's'.repeat(64),
hasSessionSecret: () => true,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key === 'settingsSyncEnabled' ? true : fallback),
getPolicy: () => ({ restrictions: {} }),
},
}));
const jar = new Map<string, string>();
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
set: (name: string, value: string) => { jar.set(name, value); },
delete: (name: string) => { jar.delete(name); },
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
}),
}));
const SERVER = 'https://mail.example.org';
const USER = 'alice@example.org';
let disks: string;
let store: SeparateDiskStore;
/** Point this process at one replica's own data dir. */
function onReplica(name: 'a' | 'b') {
vi.stubEnv('SETTINGS_DATA_DIR', path.join(disks, name, 'settings'));
}
beforeEach(async () => {
disks = mkdtempSync(path.join(tmpdir(), 'bw-settings-replicas-'));
store = separateDiskStore();
setStoreForTests(store);
vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true');
onReplica('a');
jar.clear();
const { setStalwartAuthContextInStore } = await import('@/lib/stalwart/auth-context');
setStalwartAuthContextInStore({
get: (n: string) => (jar.has(n) ? { name: n, value: jar.get(n)! } : undefined),
set: (n: string, v: string) => { jar.set(n, v); },
delete: (n: string) => { jar.delete(n); },
} as never, 0, { serverUrl: SERVER, authHeader: 'Bearer t', username: USER });
});
afterEach(() => {
setStoreForTests(null);
resetLocalStoreForTests();
vi.unstubAllEnvs();
store.cleanup();
rmSync(disks, { recursive: true, force: true });
});
function template(id: string) {
const at = '2026-10-01T00:00:00.000Z';
return { id, name: id, subject: '', body: '', isHTML: false, category: '', isFavorite: false, createdAt: at, updatedAt: at };
}
async function push(settings: Record<string, unknown>) {
const { POST } = await import('@/app/api/settings/route');
return POST(new NextRequest('https://webmail.example/api/settings', {
method: 'POST',
headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' },
body: JSON.stringify({ username: USER, serverUrl: SERVER, settings }),
}));
}
async function fetchSettings() {
const { GET } = await import('@/app/api/settings/route');
return GET(new NextRequest('https://webmail.example/api/settings', {
headers: { 'x-settings-username': USER, 'x-settings-server': SERVER },
}));
}
function storedDoc() {
return store.collection('settings', SETTINGS_COLLECTION).get(settingsId(USER, SERVER));
}
describe('settings with a shared store', () => {
it('loads on replica B what was saved through replica A', async () => {
expect((await push({ theme: 'dark' })).status).toBe(200);
onReplica('b');
const res = await fetchSettings();
expect(res.status).toBe(200);
expect((await res.json()).settings).toEqual({ theme: 'dark' });
});
it('deletes them for every replica', async () => {
await push({ theme: 'dark' });
onReplica('b');
const { DELETE } = await import('@/app/api/settings/route');
const res = await DELETE(new NextRequest('https://webmail.example/api/settings', {
method: 'DELETE',
headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' },
body: JSON.stringify({ username: USER, serverUrl: SERVER }),
}));
expect(res.status).toBe(200);
onReplica('a');
expect((await (await fetchSettings()).json()).settings).toBeNull();
});
it('answers 503 to a save over a stored document that does not decrypt and leaves its bytes alone', async () => {
const garbage = Buffer.alloc(64, 7);
await store.collection('settings', SETTINGS_COLLECTION).put(settingsId(USER, SERVER), garbage);
expect((await push({ theme: 'light' })).status).toBe(503);
expect((await fetchSettings()).status).toBe(503);
expect((await storedDoc())?.data.equals(garbage)).toBe(true);
});
it('answers 503 and writes nothing while the store is down', async () => {
await push({ theme: 'dark' });
const before = (await storedDoc())!.data;
store.down = true;
expect((await push({ theme: 'light' })).status).toBe(503);
expect((await fetchSettings()).status).toBe(503);
store.down = false;
expect((await storedDoc())!.data.equals(before)).toBe(true);
});
});
// Two saves at once, each from a tab that added a template the other
// doesn't know yet. The route used to load, merge and save without a lock,
// so the second save put its merge over the first: one template was lost,
// with two tabs on one instance as well as across replicas.
async function twoConcurrentSaves() {
await push({ templates: [], deletedTemplateIds: {} });
const [a, b] = await Promise.all([
push({ templates: [template('from-a')], deletedTemplateIds: {} }),
push({ templates: [template('from-b')], deletedTemplateIds: {} }),
]);
expect([a.status, b.status]).toEqual([200, 200]);
const saved = (await (await fetchSettings()).json()).settings as { templates: { id: string }[] };
return saved.templates.map((t) => t.id).sort();
}
describe('two saves at once', () => {
it('keep both templates with a shared store', async () => {
expect(await twoConcurrentSaves()).toEqual(['from-a', 'from-b']);
});
it('keep both templates on a single instance', async () => {
setStoreForTests(null);
expect(await twoConcurrentSaves()).toEqual(['from-a', 'from-b']);
});
});