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

133 lines
6.2 KiB
TypeScript

/**
* @vitest-environment node
*/
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Moving a single-instance install to a shared store (#1066): the admin
// config, password, state and audit log come along, a second run changes
// nothing, and the local files stay as they were.
import { steps } from '../import/admin';
import { importLocalData } from '../import';
import { getStore, setStoreForTests } from '../index';
import { resetLocalStoreForTests } from '../local';
import { separateFakeStore } from '@/lib/admin/__tests__/shared-store-fake';
import { adminConfigDocs, adminStateDocs } from '@/lib/admin/admin-store';
import { AUDIT_LOG_OPTIONS } from '@/lib/admin/audit';
import { INIT_EPOCH } from '../changes';
let dir: string;
let store: ReturnType<typeof separateFakeStore>;
/** Store calls of the shared audit log; `failAfter` makes the n+1st batch fail, as an outage would. */
const calls = { append: 0, appendMany: 0, failAfter: Infinity };
const config = (file: string) => path.join(dir, 'config', file);
const state = (file: string) => path.join(dir, 'state', file);
const LOCAL: Record<string, string> = {};
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'bw-import-admin-'));
vi.stubEnv('ADMIN_CONFIG_DIR', path.join(dir, 'config'));
vi.stubEnv('ADMIN_STATE_DIR', path.join(dir, 'state'));
mkdirSync(path.join(dir, 'config'));
mkdirSync(path.join(dir, 'state'));
const files = {
[config('config.json')]: JSON.stringify({ appName: 'Imported', setupComplete: true }, null, 2),
[config('policy.json')]: JSON.stringify({ features: { calendarEnabled: false } }, null, 2),
[config('admin.json')]: JSON.stringify({ passwordHash: '$scrypt$N=16384,r=8,p=1$c2FsdA==$aGFzaA==' }, null, 2),
[config('.config-locked')]: '2026-10-31T12:00:00.000Z',
[state('admin-state.json')]: JSON.stringify({ createdAt: 'x', lastLogin: null, passwordChangedAt: 'x', sessionGeneration: 3 }, null, 2),
[state('audit.log')]: '{"ts":"1","action":"admin.login","detail":{},"ip":"1"}\n{"ts":"2","action":"admin.logout","detail":{},"ip":"1"}\n',
};
for (const [file, content] of Object.entries(files)) {
writeFileSync(file, content);
LOCAL[file] = content;
}
resetLocalStoreForTests();
store = separateFakeStore(path.join(dir, 'store'));
const log = store.log.bind(store);
calls.append = 0;
calls.appendMany = 0;
calls.failAfter = Infinity;
store.log = (name, options) => {
const inner = log(name, options);
return {
...inner,
append: async (line) => {
calls.append++;
return inner.append(line);
},
appendMany: async (lines) => {
if (++calls.appendMany > calls.failAfter) throw new Error('store went away (test)');
return inner.appendMany(lines);
},
};
};
setStoreForTests(store);
});
afterEach(() => {
setStoreForTests(null);
resetLocalStoreForTests();
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
describe('admin import', () => {
it('copies the admin files once and leaves them on disk', async () => {
const mtimes = Object.keys(LOCAL).map((file) => statSync(file).mtimeMs);
expect((await importLocalData(steps)).imported).toEqual({ 'admin-config': 4, 'admin-state': 1, audit: 2 });
for (const id of ['config.json', 'policy.json', 'admin.json', '.config-locked']) {
expect((await adminConfigDocs().get(id))!.data.toString('utf-8')).toBe(LOCAL[config(id)]);
}
expect((await adminStateDocs().get('admin-state.json'))!.data.toString('utf-8')).toBe(LOCAL[state('admin-state.json')]);
const log = await getStore().log('audit', AUDIT_LOG_OPTIONS).readAll();
expect(log.map((line) => JSON.parse(line).action)).toEqual(['admin.login', 'admin.logout']);
expect((await importLocalData(steps)).imported).toEqual({ 'admin-config': 0, 'admin-state': 0, audit: 0 });
expect(await getStore().log('audit', AUDIT_LOG_OPTIONS).length()).toBe(2);
Object.keys(LOCAL).forEach((file, i) => {
expect(readFileSync(file, 'utf-8')).toBe(LOCAL[file]);
expect(statSync(file).mtimeMs).toBe(mtimes[i]);
});
});
it('copies a long audit log in batches, in order', async () => {
const lines = Array.from({ length: 1201 }, (_, i) => JSON.stringify({ ts: String(i), action: 'admin.login', detail: {}, ip: '1' }));
writeFileSync(state('audit.log'), lines.map((l) => `${l}\n`).join(''));
expect((await importLocalData(steps)).imported.audit).toBe(1201);
expect(calls).toMatchObject({ append: 0, appendMany: 3 });
expect(await getStore().log('audit', AUDIT_LOG_OPTIONS).readAll()).toEqual(lines);
});
it('redoes an audit import that stopped halfway, without doubling lines', async () => {
const lines = Array.from({ length: 1100 }, (_, i) => JSON.stringify({ ts: String(i), action: 'admin.login', detail: {}, ip: '1' }));
writeFileSync(state('audit.log'), lines.map((l) => `${l}\n`).join(''));
calls.failAfter = 1;
await expect(importLocalData(steps)).rejects.toThrow(/went away/);
expect(await getStore().log('audit', AUDIT_LOG_OPTIONS).length()).toBe(500);
calls.failAfter = Infinity;
expect((await importLocalData(steps)).imported.audit).toBe(1100);
expect(await getStore().log('audit', AUDIT_LOG_OPTIONS).readAll()).toEqual(lines);
expect((await importLocalData(steps)).imported.audit).toBe(0);
});
it('leaves the log of a running deployment alone on a forced re-run', async () => {
await store.epochs.bump(INIT_EPOCH);
await getStore().log('audit', AUDIT_LOG_OPTIONS).append('{"ts":"9","action":"admin.login","detail":{},"ip":"2"}');
expect((await importLocalData(steps)).imported.audit).toBe(0);
expect(await getStore().log('audit', AUDIT_LOG_OPTIONS).length()).toBe(1);
});
it('keeps what the store already holds', async () => {
await adminConfigDocs().put('config.json', '{"appName":"Already shared"}');
expect((await importLocalData(steps)).imported['admin-config']).toBe(3);
expect((await adminConfigDocs().get('config.json'))!.data.toString('utf-8')).toBe('{"appName":"Already shared"}');
});
});