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

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();
}
});
});