feat: bring the admin config, password, state and audit log along when moving to a shared store #1066

This commit is contained in:
Linus Rath
2026-10-31 23:26:52 +01:00
parent f531ef63b1
commit 4983325f32
2 changed files with 135 additions and 0 deletions
@@ -0,0 +1,81 @@
/**
* @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';
let dir: string;
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();
setStoreForTests(separateFakeStore(path.join(dir, '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('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"}');
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { ImportStep } from '../import';
import { getStore, type CollectionOptions } from '../index';
import { localCollection, localLog } from '../local';
import {
ADMIN_CONFIG_COLLECTION,
ADMIN_STATE_COLLECTION,
adminConfigOptions,
adminStateOptions,
} from '@/lib/admin/admin-store';
import { AUDIT_LOG_OPTIONS } from '@/lib/admin/audit';
/**
* Part `admin` of the import (#1066): the admin config, the admin password
* and its state, and the audit log of a single-instance install. Reads the
* local files as local mode does and writes only what the shared store
* doesn't hold yet. With ADMIN_CONFIG_READONLY the config collection is the
* mounted files on every replica, so nothing of it is copied.
*/
async function copyDocs(name: string, options: CollectionOptions, ids: string[]): Promise<number> {
const local = localCollection(name, options);
const shared = getStore().collection(name, options);
let written = 0;
for (const id of ids) {
const doc = await local.get(id);
if (!doc) continue;
if ((await shared.put(id, doc.data, { ifVersion: null })).ok) written++;
}
return written;
}
export const steps: ImportStep[] = [
{
name: 'admin-config',
run: () => copyDocs(ADMIN_CONFIG_COLLECTION, adminConfigOptions, ['config.json', 'policy.json', 'admin.json', '.config-locked']),
},
{
name: 'admin-state',
run: () => copyDocs(ADMIN_STATE_COLLECTION, adminStateOptions, ['admin-state.json']),
},
{
// The current audit.log, as the dashboard shows it; the rotated files
// stay on disk. Only into an empty shared log, so a second run (or a
// replica that joins later) adds nothing twice.
name: 'audit',
run: async () => {
const shared = getStore().log('audit', AUDIT_LOG_OPTIONS);
if ((await shared.length()) > 0) return 0;
const lines = (await localLog('audit', AUDIT_LOG_OPTIONS).readAll()).slice(-AUDIT_LOG_OPTIONS.maxEntries);
for (const line of lines) await shared.append(line);
return lines.length;
},
},
];