mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
170 lines
6.3 KiB
TypeScript
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']);
|
|
});
|
|
});
|