mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@/lib/admin/config-manager', () => ({
|
|
configManager: { get: vi.fn((_key: string, fallback: unknown) => fallback) },
|
|
}));
|
|
|
|
import { bootState, getStore, setBootState, setStoreForTests } from '../index';
|
|
import { resetLocalStoreForTests } from '../local';
|
|
import {
|
|
INIT_EPOCH,
|
|
announceChange,
|
|
markEpochsSeen,
|
|
onSharedChange,
|
|
pollChanges,
|
|
resetChangesForTests,
|
|
} from '../changes';
|
|
import { bootSharedMode } from '../boot';
|
|
import {
|
|
ensureSharedSessionSecret,
|
|
getSessionSecret,
|
|
resetSharedSessionSecretForTests,
|
|
SHARED_SECRET_KEY,
|
|
} from '@/lib/auth/session-secret';
|
|
import { fakeSharedStore } from './fake-shared';
|
|
|
|
let stateDir: string;
|
|
|
|
beforeEach(() => {
|
|
stateDir = mkdtempSync(path.join(tmpdir(), 'bulwark-boot-'));
|
|
vi.stubEnv('ADMIN_STATE_DIR', stateDir);
|
|
vi.stubEnv('SESSION_SECRET', '');
|
|
vi.stubEnv('SESSION_SECRET_FILE', '');
|
|
resetLocalStoreForTests();
|
|
resetChangesForTests();
|
|
resetSharedSessionSecretForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
setStoreForTests(null);
|
|
resetChangesForTests();
|
|
vi.unstubAllEnvs();
|
|
rmSync(stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const noImport = async () => ({ imported: {} });
|
|
|
|
describe('change notices', () => {
|
|
it('runs this process\'s listeners at once in local mode', async () => {
|
|
const seen: string[] = [];
|
|
onSharedChange('config', (origin) => void seen.push(origin));
|
|
await announceChange('config');
|
|
expect(seen).toEqual(['local']);
|
|
});
|
|
|
|
it('runs listeners for another replica\'s change on the next poll, once', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
await store.epochs.bump(INIT_EPOCH);
|
|
await markEpochsSeen();
|
|
const seen: string[] = [];
|
|
onSharedChange('policy', (origin) => void seen.push(origin));
|
|
await store.epochs.bump('policy'); // another replica saved
|
|
await pollChanges();
|
|
await pollChanges();
|
|
expect(seen).toEqual(['remote']);
|
|
});
|
|
|
|
it('does not reload its own change, but does when another landed in between', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
await store.epochs.bump(INIT_EPOCH);
|
|
await markEpochsSeen();
|
|
const seen: string[] = [];
|
|
onSharedChange('config', (origin) => void seen.push(origin));
|
|
await announceChange('config');
|
|
await pollChanges();
|
|
expect(seen).toEqual(['local']);
|
|
await store.epochs.bump('config'); // other replica
|
|
await announceChange('config'); // ours right after
|
|
await pollChanges();
|
|
expect(seen).toEqual(['local', 'local', 'remote']);
|
|
});
|
|
|
|
it('sends a change notice again when the store was unreachable right after the write', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
await store.epochs.bump(INIT_EPOCH);
|
|
await markEpochsSeen();
|
|
const bump = store.epochs.bump.bind(store.epochs);
|
|
let fail = true;
|
|
store.epochs.bump = async (area: string) => {
|
|
if (fail) throw new Error('connection dropped');
|
|
return bump(area);
|
|
};
|
|
await announceChange('config');
|
|
expect((await store.epochs.all()).config).toBeUndefined();
|
|
await pollChanges(); // still down
|
|
expect((await store.epochs.all()).config).toBeUndefined();
|
|
fail = false;
|
|
await pollChanges();
|
|
expect((await store.epochs.all()).config).toBe(1);
|
|
await pollChanges();
|
|
expect((await store.epochs.all()).config).toBe(1); // sent once, not every poll
|
|
});
|
|
|
|
it('retries a listener that failed on the next poll', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
await store.epochs.bump(INIT_EPOCH);
|
|
await markEpochsSeen();
|
|
let calls = 0;
|
|
onSharedChange('plugins', () => {
|
|
calls++;
|
|
if (calls === 1) throw new Error('store hiccup');
|
|
});
|
|
await store.epochs.bump('plugins');
|
|
await pollChanges();
|
|
await pollChanges();
|
|
await pollChanges();
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('goes to store-lost when the store forgets the deployment, and back when it returns', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
setBootState('ready');
|
|
await pollChanges(); // no _init epoch: an emptied store
|
|
expect(bootState()).toBe('store-lost');
|
|
const reloaded: string[] = [];
|
|
onSharedChange('config', (origin) => void reloaded.push(origin));
|
|
await store.epochs.bump(INIT_EPOCH); // restored
|
|
await pollChanges();
|
|
expect(bootState()).toBe('ready');
|
|
expect(reloaded).toEqual(['remote']);
|
|
});
|
|
});
|
|
|
|
describe('session secret with a shared store', () => {
|
|
it('gives every replica the same generated secret', async () => {
|
|
setStoreForTests(fakeSharedStore());
|
|
const created = await Promise.all([ensureSharedSessionSecret(), ensureSharedSessionSecret(), ensureSharedSessionSecret()]);
|
|
expect(created.filter(Boolean)).toHaveLength(1);
|
|
const stored = await getStore().kv.get(SHARED_SECRET_KEY);
|
|
expect(stored).toMatch(/^[A-Za-z0-9+/]{43}=$/);
|
|
expect(getSessionSecret()).toBe(stored);
|
|
});
|
|
|
|
it('ignores the state dir file in shared mode and lets SESSION_SECRET win', async () => {
|
|
writeFileSync(path.join(stateDir, '.session-secret'), 'local-file-secret-local-file-secret-xx');
|
|
setStoreForTests(fakeSharedStore());
|
|
await ensureSharedSessionSecret();
|
|
expect(getSessionSecret()).not.toBe('local-file-secret-local-file-secret-xx');
|
|
vi.stubEnv('SESSION_SECRET', 'from-env-from-env-from-env-from-env-xx');
|
|
expect(getSessionSecret()).toBe('from-env-from-env-from-env-from-env-xx');
|
|
});
|
|
});
|
|
|
|
describe('bootSharedMode', () => {
|
|
it('initialises an empty store: secret, marker, then loads and becomes ready', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
const order: string[] = [];
|
|
await bootSharedMode({
|
|
version: '9.9.9',
|
|
importLocal: async () => { order.push('import'); return { imported: { settings: 2 } }; },
|
|
load: async () => { order.push('load'); },
|
|
});
|
|
expect(order).toEqual(['import', 'load']);
|
|
expect(bootState()).toBe('ready');
|
|
expect((await store.epochs.all())[INIT_EPOCH]).toBe(1);
|
|
expect(JSON.parse((await store.kv.get('meta:initialized'))!)).toMatchObject({ imported: { settings: 2 } });
|
|
expect(getSessionSecret()).toBe(await store.kv.get(SHARED_SECRET_KEY));
|
|
expect((await store.replicas.recent(60_000)).map((r) => r.version)).toEqual(['9.9.9']);
|
|
});
|
|
|
|
it('imports this install\'s generated secret before creating one', async () => {
|
|
writeFileSync(path.join(stateDir, '.session-secret'), 'old-install-secret-old-install-secret');
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
await bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
|
|
expect(await store.kv.get(SHARED_SECRET_KEY)).toBe('old-install-secret-old-install-secret');
|
|
expect(getSessionSecret()).toBe('old-install-secret-old-install-secret');
|
|
});
|
|
|
|
it('joins an initialised store without importing again', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
await bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
|
|
const importLocal = vi.fn(noImport);
|
|
resetSharedSessionSecretForTests();
|
|
await bootSharedMode({ version: '1', importLocal, load: async () => {} });
|
|
expect(importLocal).not.toHaveBeenCalled();
|
|
expect(bootState()).toBe('ready');
|
|
expect((await store.epochs.all())[INIT_EPOCH]).toBe(1);
|
|
});
|
|
|
|
it('refuses to serve when its own local data was encrypted with another secret', async () => {
|
|
const store = fakeSharedStore();
|
|
setStoreForTests(store);
|
|
await bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
|
|
mkdirSync(stateDir, { recursive: true });
|
|
writeFileSync(path.join(stateDir, '.session-secret'), 'a-different-install-secret-a-different');
|
|
const load = vi.fn(async () => {});
|
|
await bootSharedMode({ version: '1', importLocal: noImport, load });
|
|
expect(bootState()).toBe('blocked');
|
|
expect(load).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('waits for a store that is down at start', async () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval'] });
|
|
try {
|
|
const store = fakeSharedStore();
|
|
store.down = true;
|
|
setStoreForTests(store);
|
|
const done = bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(bootState()).toBe('starting');
|
|
store.down = false;
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
await done;
|
|
expect(bootState()).toBe('ready');
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|