Files
Codeman/test/routes/config-backup-routes.test.ts
T
Codeman maintainer 0952a2ee47 fix(config-backup): a restore that fails partway names what it restored and what failed
Merge-time fix for #661. A restore that could not write one file (a full
disk, a directory where the file should be) used to stop there and answer a
bare 500, naming neither the files it had already put back nor the
pre-restore copy, and the Settings form was not re-read, so the next Save
wrote the old settings back over a restored settings.json.

restoreConfigBackup now writes every verified file it can, then throws
ConfigBackupPartialRestoreError with restored, failed (name and error),
untouched and safetySnapshot. The route runs the same reload hooks and audit
for the files written as a full restore does, and answers 500 with the error
envelope plus those fields (and restartRequired) in data, as the tab-layout
409 carries its layout. Settings refills the form when settings.json is among
the restored files, also on that path. Tests: a unit case where a later file
is still restored, the clis.json-as-a-directory route case, and two vm cases
for the Settings handler.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 19:14:08 +02:00

331 lines
16 KiB
TypeScript

/**
* @fileoverview /api/config-backups: list, back up now, restore. Settings are read fresh from
* settings.json, so a folder or retention change applies on the next request.
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerConfigBackupRoutes, runConfigBackup } from '../../src/web/routes/config-backup-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js';
import { intentStore } from '../../src/intent-store.js';
let custom: string;
const settings = (extra: Record<string, unknown> = {}) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'before', ...extra }));
};
beforeEach(() => {
custom = mkdtempSync(join(tmpdir(), 'cfg-backup-route-'));
rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true });
settings();
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(custom, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true });
});
describe('/api/config-backups', () => {
it('lists the effective settings, the default folder and the tracked files', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'GET', url: '/api/config-backups' });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.settings).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
expect(d.dir).toBe(defaultConfigBackupDir(getDataDir()));
expect(d.tracked).toContain('settings.json');
expect(d.backups).toEqual([]);
});
it('backs up now into the default folder and lists it', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const made = await app.inject({ method: 'POST', url: '/api/config-backups' });
expect(made.statusCode).toBe(200);
expect(made.json().data.status).toBe('created');
const list = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data;
expect(list.backups).toHaveLength(1);
expect(list.backups[0].reason).toBe('manual');
expect(list.backups[0].files.map((f: { name: string }) => f.name)).toContain('settings.json');
// Names and sizes only, never content.
expect(JSON.stringify(list)).not.toContain('before');
});
it('follows a custom folder and retention from settings on the next request', async () => {
const dir = join(custom, 'snap');
settings({ configBackupDir: dir, configBackupKeepCount: 2, configBackupKeepDays: 0 });
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
for (let i = 0; i < 4; i++) {
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dir, configBackupKeepCount: 2, i }));
await app.inject({ method: 'POST', url: '/api/config-backups' });
await new Promise((r) => setTimeout(r, 5));
}
const d = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data;
expect(d.dir).toBe(dir);
expect(d.backups).toHaveLength(2);
expect(existsSync(defaultConfigBackupDir(getDataDir()))).toBe(false);
});
it('restores a backup, reports what needs a restart and keeps a pre-restore copy', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"schemaVersion":1,"clis":{}}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' }));
writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"clis":{"evil":{}}}');
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.restored).toEqual(expect.arrayContaining(['settings.json', 'clis.json']));
expect(d.restartRequired).not.toContain('clis.json'); // reloaded by the restore itself
expect(d.restartRequired).toContain('settings.json'); // its watcher toggles are not re-run by a restore
expect(d.safetySnapshot).toBeTruthy();
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before');
});
it('a restore that fails partway names what it restored, what failed and the pre-restore copy', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const clis = join(dirname(SETTINGS_PATH), 'clis.json');
writeFileSync(clis, '{"schemaVersion":1,"clis":{}}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' }));
// A file the server cannot replace: settings.json (first in the list) goes back, clis.json cannot.
rmSync(clis);
mkdirSync(clis);
try {
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode).toBe(500);
const body = res.json();
expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
expect(body.error).toContain('clis.json');
expect(body.data.restored).toContain('settings.json');
expect(body.data.failed.map((f: { name: string }) => f.name)).toEqual(['clis.json']);
expect(body.data.restartRequired).toContain('settings.json');
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before');
// The undo copy exists, holds the state the restore replaced, and is the one named.
const safety = body.data.safetySnapshot;
expect(safety).toBeTruthy();
expect(body.error).toContain(safety);
const listed = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups;
expect(listed.find((b: { id: string }) => b.id === safety)?.reason).toBe('pre-restore');
} finally {
rmSync(clis, { recursive: true, force: true });
}
});
it('a restored intents.json is not reverted by the next captured prompt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-'));
try {
intentStore.recordPrompt(undefined, dir, 's1', 'first prompt about the build pipeline');
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
intentStore.recordPrompt(undefined, dir, 's1', 'second prompt about the build pipeline');
await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
intentStore.recordPrompt(undefined, dir, 's1', 'third prompt about the build pipeline');
expect(intentStore.getProfile(undefined, dir).recentPrompts).toHaveLength(2);
} finally {
intentStore.deleteProfile(undefined, dir);
rmSync(dir, { recursive: true, force: true });
}
});
it('refuses a custom folder whose parent is missing instead of creating it', async () => {
settings({ configBackupDir: join(custom, 'no', 'such', 'dir') });
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'POST', url: '/api/config-backups' });
expect(res.statusCode).toBe(503);
expect(existsSync(join(custom, 'no'))).toBe(false);
});
it('answers 404 for an unknown backup and 404 for a traversal attempt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
for (const id of ['cfg-19990101-000000', '..%2F..%2Fetc', 'photos']) {
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode, id).toBe(404);
}
});
it('is admin only in multi-user mode: a non-admin is refused on every verb', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'bob', role: 'user' },
});
for (const [method, url] of [
['GET', '/api/config-backups'],
['POST', '/api/config-backups'],
['POST', '/api/config-backups/cfg-20261001-120000/restore'],
['GET', '/api/config-backups/diff?from=cfg-20261001-120000'],
] as const) {
expect((await app.inject({ method, url })).statusCode, `${method} ${url}`).toBe(403);
}
});
it('multi-user: a restore is written to the admin audit log', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
const auditPath = join(getDataDir(), 'admin-audit.jsonl');
const written = () => existsSync(auditPath) && readFileSync(auditPath, 'utf8').includes('config_backup_restore');
for (let i = 0; i < 40 && !written(); i++) await new Promise((r) => setTimeout(r, 25));
const last = JSON.parse(readFileSync(auditPath, 'utf8').trim().split('\n').pop()!);
expect(last).toMatchObject({ admin: 'root', action: 'config_backup_restore', target: id });
expect(last.detail.restored).toContain('settings.json');
rmSync(auditPath, { force: true });
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().success).toBe(true);
});
});
describe('GET /api/config-backups/diff', () => {
it('compares a backup with the running config, defaulting `to` to current', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const made = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data;
settings({ displayName: 'after' });
const res = await app.inject({ method: 'GET', url: `/api/config-backups/diff?from=${made.id}` });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.to).toBe('current');
expect(d.files.find((f: { name: string }) => f.name === 'settings.json').changes).toEqual([
{ path: 'displayName', kind: 'changed', before: '"before"', after: '"after"' },
]);
});
it('400 without `from`, 404 for an unknown backup', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
expect((await app.inject({ method: 'GET', url: '/api/config-backups/diff' })).statusCode).toBe(400);
const missing = await app.inject({ method: 'GET', url: '/api/config-backups/diff?from=cfg-20200101-000000' });
expect(missing.statusCode).toBe(404);
});
});
describe('retention survives an unreadable or reset settings.json', () => {
const DAY = 86_400_000;
const BASE = Date.UTC(2025, 0, 1);
const dir = () => defaultConfigBackupDir(getDataDir());
/** 30 snapshots a day apart, made under a long-history retention (100 copies, no age limit). */
function seed(withKeys = true) {
settings({ configBackupKeepCount: 100, configBackupKeepDays: 0 });
for (let i = 0; i < 30; i++) {
writeFileSync(
SETTINGS_PATH,
JSON.stringify(
withKeys
? { displayName: `v${i}`, configBackupKeepCount: 100, configBackupKeepDays: 0 }
: { displayName: `v${i}` }
)
);
createConfigBackup({
dataDir: getDataDir(),
backupDir: dir(),
reason: 'auto',
now: BASE + i * DAY,
keepCount: 100,
keepDays: 0,
});
}
expect(listConfigBackups(dir())).toHaveLength(30);
}
it('a settings.json that does not parse still gets a snapshot but prunes nothing', async () => {
seed();
writeFileSync(SETTINGS_PATH, '{ "displayName": "half-written", }');
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(dir())).toHaveLength(31);
});
it('a reset settings.json prunes with the retention the newest snapshot recorded, not the defaults', async () => {
seed();
writeFileSync(SETTINGS_PATH, '{}');
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
// aged manifests (a year old) and keepDays 0: the defaults (20 copies, 30 days) would leave 20
expect(listConfigBackups(dir())).toHaveLength(31);
});
it.each([true, false])(
'a restore after a reset keeps the retention (the backup %s the four keys)',
async (withKeys) => {
seed(withKeys);
const oldest = listConfigBackups(dir()).at(-1)!.id;
writeFileSync(SETTINGS_PATH, '{}'); // the reset
expect((await runConfigBackup('auto'))?.removed).toEqual([]);
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${oldest}/restore` });
expect(res.statusCode).toBe(200);
// the restored file carries the retention, or the recorded one still applies via the pre-restore copy
const out = await runConfigBackup('auto');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(dir()).length).toBeGreaterThanOrEqual(32);
const status = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings;
expect(status).toMatchObject({ keepCount: 100, keepDays: 0 }); // what the form shows is what the pass uses
}
);
it("re-entering a custom folder after a reset finds that folder's recorded retention", async () => {
const folder = join(custom, 'kept');
settings({ configBackupDir: folder, configBackupKeepCount: 100, configBackupKeepDays: 0 });
for (let i = 0; i < 30; i++) {
createConfigBackup({
dataDir: getDataDir(),
backupDir: folder,
reason: 'auto',
force: true,
now: BASE + i * DAY,
keepCount: 100,
keepDays: 0,
});
}
writeFileSync(SETTINGS_PATH, '{}'); // the reset drops the folder too
await runConfigBackup('auto'); // goes to the default folder, which holds none of those manifests
// the user enters the folder again and saves; the form no longer sends retention it only displayed
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: folder, configBackupEnabled: true }));
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const reported = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings;
expect(reported).toMatchObject({ keepCount: 100, keepDays: 0 });
const out = await runConfigBackup('auto');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(folder).length).toBeGreaterThanOrEqual(30);
});
it('only the retention key the file lacks falls back to the recorded one', async () => {
seed();
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepDays: 3650 }));
await runConfigBackup('auto');
expect(listConfigBackups(dir())).toHaveLength(31); // keepCount 100 from the manifests, not the default 20
});
it('a settings.json that sets the retention still wins', async () => {
seed();
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 5, configBackupKeepDays: 0 }));
await runConfigBackup('auto');
expect(listConfigBackups(dir())).toHaveLength(5);
});
it('listing never creates config-backup-id', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const idFile = join(getDataDir(), 'config-backup-id');
rmSync(idFile, { force: true });
expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups).toEqual([]);
expect(existsSync(idFile)).toBe(false);
});
});