mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge pull request #661: feat(config): timestamped config backups with a configurable folder and retention
Conflicts in CLAUDE.md, README.md, docs/api-reference.md and src/web/server.ts were additions beside #662's Copilot quota read and #666's keep-warm sections; both sides kept. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
3b6818449e
27 files changed
+3299
-6
No files matched your search
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @fileoverview The snapshot origin must survive a hostname change (a Docker container gets a new
|
||||
* hostname on every recreate) and still differ between data dirs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const host = vi.hoisted(() => ({ name: 'container-a' }));
|
||||
vi.mock('node:os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:os')>()),
|
||||
hostname: () => host.name,
|
||||
}));
|
||||
|
||||
import {
|
||||
configBackupOrigin,
|
||||
createConfigBackup,
|
||||
listConfigBackups,
|
||||
pruneConfigBackups,
|
||||
restoreConfigBackup,
|
||||
} from '../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
let data: string;
|
||||
let backups: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'cfg-origin-'));
|
||||
data = join(root, 'data');
|
||||
backups = join(root, 'backups');
|
||||
mkdirSync(data, { recursive: true });
|
||||
writeFileSync(join(data, 'settings.json'), '{"a":1}');
|
||||
host.name = 'container-a';
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('configBackupOrigin', () => {
|
||||
it('is made once, kept in the data dir, and differs between data dirs', () => {
|
||||
const a = configBackupOrigin(data);
|
||||
expect(configBackupOrigin(data)).toBe(a);
|
||||
expect(readFileSync(join(data, 'config-backup-id'), 'utf8').trim()).toBe(a);
|
||||
const other = join(root, 'other');
|
||||
expect(configBackupOrigin(other)).not.toBe(a);
|
||||
});
|
||||
|
||||
it('keeps snapshots listed, restorable and prunable after the hostname changes', () => {
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: 1_000_000 }) as {
|
||||
id: string;
|
||||
};
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: 2_000_000 });
|
||||
host.name = 'container-b'; // the container was recreated
|
||||
const origin = configBackupOrigin(data);
|
||||
expect(listConfigBackups(backups, origin)).toHaveLength(2);
|
||||
writeFileSync(join(data, 'settings.json'), '{"a":2}'); // a change, so the restore takes a pre-restore copy
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).not.toThrow();
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 1, keepDays: 0, origin });
|
||||
expect(removed).toHaveLength(2); // 3 now: the restore took a pre-restore copy
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @fileoverview Restoring settings.json from inside App Settings must refill the form, or Save
|
||||
* would PUT the pre-restore values back over the restored file. Evaluates the real settings-ui.js
|
||||
* against a stub app.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function makeApp(
|
||||
restored: string[],
|
||||
confirmed = true,
|
||||
reply: { ok: boolean; body: unknown } = { ok: true, body: { success: true, data: { restored, restartRequired: [] } } }
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const confirms: string[] = [];
|
||||
const toasts: Array<[string, string]> = [];
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: {},
|
||||
confirm: (m: string) => (confirms.push(m), confirmed),
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
Object.assign(app, {
|
||||
_configBackups: [
|
||||
{
|
||||
id: 'cfg-1',
|
||||
files: [{ name: 'settings.json' }, { name: 'users.json' }, { name: '.env' }, { name: 'clis.json' }],
|
||||
},
|
||||
],
|
||||
_apiPost: async () => ({ ok: reply.ok, json: async () => reply.body }),
|
||||
loadAppSettingsFromServer: async () => void calls.push('reload-settings'),
|
||||
openAppSettings: () => void calls.push('refill-form'),
|
||||
loadConfigBackups: () => void calls.push('reload-list'),
|
||||
showToast: (m: string, type: string) => void toasts.push([m, type]),
|
||||
});
|
||||
return { app, calls, confirms, toasts };
|
||||
}
|
||||
|
||||
describe('restoreConfigBackup (Settings UI)', () => {
|
||||
it('re-reads settings into the form when settings.json was restored', async () => {
|
||||
const { app, calls } = makeApp(['settings.json', 'clis.json']);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
|
||||
});
|
||||
|
||||
it('leaves the form alone when settings.json was not part of the restore', async () => {
|
||||
const { app, calls } = makeApp(['clis.json']);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-list']);
|
||||
});
|
||||
|
||||
it('a restore that failed partway still refills the form when settings.json was among the files written', async () => {
|
||||
const { app, calls, toasts } = makeApp([], true, {
|
||||
ok: false,
|
||||
body: {
|
||||
success: false,
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
error: 'Restore incomplete: could not write clis.json (EISDIR); settings.json restored',
|
||||
data: {
|
||||
restored: ['settings.json'],
|
||||
failed: [{ name: 'clis.json', error: 'EISDIR' }],
|
||||
safetySnapshot: 'cfg-2',
|
||||
restartRequired: ['settings.json'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0][1]).toBe('error');
|
||||
expect(toasts[0][0]).toContain('clis.json');
|
||||
});
|
||||
|
||||
it('a refused restore (nothing written) only reports the error', async () => {
|
||||
const { app, calls, toasts } = makeApp([], true, {
|
||||
ok: false,
|
||||
body: { success: false, errorCode: 'CONFLICT', error: 'settings.json does not match its checksum' },
|
||||
});
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual([]);
|
||||
expect(toasts).toEqual([['settings.json does not match its checksum', 'error']]);
|
||||
});
|
||||
|
||||
it('names the files being replaced and calls out the credential ones', async () => {
|
||||
const { app, confirms } = makeApp(['settings.json'], false);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(confirms[0]).toContain('settings.json, users.json, .env, clis.json');
|
||||
expect(confirms[0]).toContain('credentials');
|
||||
expect(confirms[0]).toMatch(/users\.json, \.env\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfigBackups (retention fields)', () => {
|
||||
function load(saved: Record<string, unknown>) {
|
||||
const inputs: Record<string, { value: string; placeholder: string }> = {
|
||||
appSettingsConfigBackupKeepCount: { value: '20', placeholder: '' },
|
||||
appSettingsConfigBackupKeepDays: { value: '30', placeholder: '' },
|
||||
appSettingsConfigBackupDir: { value: '', placeholder: '' },
|
||||
};
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: (id: string) => inputs[id] ?? null },
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
Object.assign(app, {
|
||||
$: () => ({ textContent: '', innerHTML: '' }),
|
||||
loadAppSettingsFromStorage: () => saved,
|
||||
_api: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { settings: { keepCount: 100, keepDays: 0 }, dir: '/d', defaultDir: '/d', backups: [] },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return { app, inputs };
|
||||
}
|
||||
|
||||
it('shows the retention the passes use when settings.json has none of the keys (a reset file)', async () => {
|
||||
const { app, inputs } = load({});
|
||||
await (app.loadConfigBackups as () => Promise<void>)();
|
||||
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe(100);
|
||||
expect(inputs.appSettingsConfigBackupKeepDays.value).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves the form alone when the saved settings state the retention', async () => {
|
||||
const { app, inputs } = load({ configBackupKeepCount: 7, configBackupKeepDays: 9 });
|
||||
await (app.loadConfigBackups as () => Promise<void>)();
|
||||
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save and the displayed retention', () => {
|
||||
function payload(
|
||||
prev: Record<string, unknown>,
|
||||
touched: { count?: boolean; days?: boolean },
|
||||
count = '100',
|
||||
days = '0'
|
||||
) {
|
||||
const inputs: Record<string, { value: string; dataset: Record<string, string> }> = {
|
||||
appSettingsConfigBackupKeepCount: { value: count, dataset: touched.count ? { touched: '1' } : {} },
|
||||
appSettingsConfigBackupKeepDays: { value: days, dataset: touched.days ? { touched: '1' } : {} },
|
||||
};
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: (id: string) => inputs[id] },
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
return (app._configBackupRetentionPayload as (p: Record<string, unknown>) => Record<string, unknown>).call(
|
||||
app,
|
||||
prev
|
||||
);
|
||||
}
|
||||
|
||||
it('leaves out a retention value that was only displayed (a reset settings.json)', () => {
|
||||
expect(payload({}, {})).toEqual({});
|
||||
});
|
||||
|
||||
it('sends a value that was saved before, or edited here, and only that one', () => {
|
||||
expect(payload({ configBackupKeepCount: 100 }, {})).toEqual({ configBackupKeepCount: 100 });
|
||||
expect(payload({}, { days: true })).toEqual({ configBackupKeepDays: 0 });
|
||||
expect(payload({}, { count: true, days: true }, '5', '7')).toEqual({
|
||||
configBackupKeepCount: 5,
|
||||
configBackupKeepDays: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps like the form always did', () => {
|
||||
expect(payload({}, { count: true, days: true }, '1', '99999')).toEqual({
|
||||
configBackupKeepCount: 2,
|
||||
configBackupKeepDays: 3650,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* @fileoverview Config backups (src/config-backup.ts): what is tracked, when a snapshot is made,
|
||||
* how retention prunes, and that restore verifies before it writes. Pure fs over temp dirs;
|
||||
* port: N/A.
|
||||
*/
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CONFIG_BACKUP_FILES,
|
||||
ConfigBackupCorruptError,
|
||||
ConfigBackupNotFoundError,
|
||||
ConfigBackupPartialRestoreError,
|
||||
createConfigBackup,
|
||||
defaultConfigBackupDir,
|
||||
diffConfigBackups,
|
||||
listConfigBackups,
|
||||
normalizeBackupDir,
|
||||
pruneConfigBackups,
|
||||
readConfigBackupSettings,
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
configBackupOrigin,
|
||||
latestBackupRetention,
|
||||
sweepConfigBackupTemps,
|
||||
} from '../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
let data: string;
|
||||
let backups: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'cfg-backup-'));
|
||||
data = join(root, 'data');
|
||||
backups = join(root, 'backups');
|
||||
mkdirSync(data);
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const put = (name: string, text: string) => writeFileSync(join(data, name), text);
|
||||
const DAY = 86_400_000;
|
||||
const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
|
||||
|
||||
describe('settings', () => {
|
||||
it('defaults to ON, 20 copies, 30 days, default folder', () => {
|
||||
expect(readConfigBackupSettings({})).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
|
||||
expect(readConfigBackupSettings(null).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('only an explicit false turns it off, and numbers are clamped to their ranges', () => {
|
||||
expect(readConfigBackupSettings({ configBackupEnabled: false }).enabled).toBe(false);
|
||||
const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 });
|
||||
expect(s.keepCount).toBe(500);
|
||||
expect(s.keepDays).toBe(0);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(2);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7);
|
||||
});
|
||||
|
||||
it('expands ~ and refuses anything that is not an absolute path', () => {
|
||||
expect(normalizeBackupDir('~/cfg', '/home/u')).toBe('/home/u/cfg');
|
||||
expect(normalizeBackupDir('/mnt/backups/codeman', '/home/u')).toBe('/mnt/backups/codeman');
|
||||
for (const bad of ['', ' ', 'relative/dir', './here', undefined, 42, '/x\0y']) {
|
||||
expect(normalizeBackupDir(bad as never, '/home/u'), String(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses <data dir>/backups/config by default and never the data dir itself', () => {
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({}), '/d')).toBe(defaultConfigBackupDir('/d'));
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/elsewhere' }), '/d')).toBe(
|
||||
'/elsewhere'
|
||||
);
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/d' }), '/d')).toBe(
|
||||
defaultConfigBackupDir('/d')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConfigBackup', () => {
|
||||
it('snapshots only the tracked files that exist, with a manifest', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
put('state.json', '{"runtime":true}'); // not config: must not be copied
|
||||
put('hook-secret', 'x'); // regenerated: must not be copied
|
||||
const r = createConfigBackup({
|
||||
dataDir: data,
|
||||
backupDir: backups,
|
||||
reason: 'startup',
|
||||
appVersion: '1.2.3',
|
||||
now: T0,
|
||||
});
|
||||
expect(r.status).toBe('created');
|
||||
const [b] = listConfigBackups(backups);
|
||||
expect(b.files.map((f) => f.name).sort()).toEqual(['clis.json', 'settings.json']);
|
||||
expect(b.reason).toBe('startup');
|
||||
expect(b.appVersion).toBe('1.2.3');
|
||||
expect(b.createdAt).toBe(T0);
|
||||
expect(existsSync(join(backups, b.id, 'state.json'))).toBe(false);
|
||||
expect(readFileSync(join(backups, b.id, 'settings.json'), 'utf8')).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('tracks a fixed allowlist', () => {
|
||||
expect([...CONFIG_BACKUP_FILES]).toContain('settings.json');
|
||||
expect([...CONFIG_BACKUP_FILES]).not.toContain('state.json');
|
||||
expect([...CONFIG_BACKUP_FILES]).not.toContain('hook-secret');
|
||||
});
|
||||
|
||||
it('makes no second snapshot while nothing changed, and one when something does', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
|
||||
put('settings.json', '{"a":2}');
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
expect(listConfigBackups(backups)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not count a self-refreshed discovery timestamp as a change', () => {
|
||||
const hosts = (stamp: string, model = 'm1') =>
|
||||
JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] });
|
||||
put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z'));
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
|
||||
// The 5-minute discovery poll rewrites only the timestamp: still the same state.
|
||||
put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z'));
|
||||
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
|
||||
// A real edit next to a refreshed timestamp is still a change, stored exactly as written.
|
||||
const edited = hosts('2026-10-12T08:54:22.025Z', 'm2');
|
||||
put('custom-model-hosts.json', edited);
|
||||
const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 });
|
||||
expect(third.status).toBe('created');
|
||||
expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'webviews.json',
|
||||
(n: number) =>
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'w1',
|
||||
name: 'Grafana',
|
||||
url: 'http://localhost:3001',
|
||||
embedMode: 'proxy',
|
||||
trusted: false,
|
||||
createdAt: 1,
|
||||
lastOpenedAt: n,
|
||||
},
|
||||
]),
|
||||
'name',
|
||||
],
|
||||
[
|
||||
'docker-cases.json',
|
||||
(n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]),
|
||||
'hostId',
|
||||
],
|
||||
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
|
||||
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
|
||||
[
|
||||
'intents.json',
|
||||
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
|
||||
'goals',
|
||||
],
|
||||
])('does not count activity in %s as a change, but does count an edit next to it', (name, make, editKey) => {
|
||||
put(name, make(1));
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put(name, make(2));
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 })).toEqual({
|
||||
status: 'unchanged',
|
||||
id: (first as { id: string }).id,
|
||||
});
|
||||
put(name, make(3).replace(new RegExp(`"${editKey}":"[^"]*"`), `"${editKey}":"edited"`));
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
});
|
||||
|
||||
it('compares a tracked file that is not valid JSON by its bytes', () => {
|
||||
put('custom-model-hosts.json', 'not json lastDiscoveredAt 1');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put('custom-model-hosts.json', 'not json lastDiscoveredAt 2');
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
});
|
||||
|
||||
it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 });
|
||||
const forced = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: T0 });
|
||||
expect(forced.status).toBe('created');
|
||||
const ids = listConfigBackups(backups).map((b) => b.id);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
});
|
||||
|
||||
it('reports empty, creating nothing, when no tracked file exists', () => {
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x' })).toEqual({ status: 'empty' });
|
||||
expect(existsSync(backups)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps snapshots private: 0700 folder and 0600 files', () => {
|
||||
put('settings.json', '{}');
|
||||
put('.env', 'CODEMAN_PASSWORD=secret');
|
||||
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
|
||||
const id = (r as { id: string }).id;
|
||||
expect(statSync(join(backups, id)).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(backups, id, '.env')).mode & 0o777).toBe(0o600);
|
||||
expect(statSync(join(backups, id, 'manifest.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('leaves no temp folder behind', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
|
||||
const stray = readdirSync(backups).filter((n) => n.startsWith('.tmp-'));
|
||||
expect(stray).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retention', () => {
|
||||
const makeN = (n: number) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
put('settings.json', JSON.stringify({ i }));
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + i * DAY });
|
||||
}
|
||||
};
|
||||
|
||||
it('keeps the newest keepCount', () => {
|
||||
makeN(6);
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 3, keepDays: 0, now: T0 + 6 * DAY });
|
||||
expect(removed).toHaveLength(3);
|
||||
const left = listConfigBackups(backups);
|
||||
expect(left).toHaveLength(3);
|
||||
expect(left.map((b) => b.createdAt)).toEqual([T0 + 5 * DAY, T0 + 4 * DAY, T0 + 3 * DAY]);
|
||||
});
|
||||
|
||||
it('drops anything older than keepDays, but never the newest', () => {
|
||||
makeN(4); // T0 .. T0+3d
|
||||
// At T0+100d everything is older than 30 days: the newest still survives.
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 50, keepDays: 30, now: T0 + 100 * DAY });
|
||||
expect(removed).toHaveLength(3);
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 3 * DAY]);
|
||||
});
|
||||
|
||||
it('applies both limits together and keepDays 0 means no age limit', () => {
|
||||
makeN(5);
|
||||
expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]);
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY });
|
||||
// Newest first: T0+4d, +3d, +2d, +1d, T0. A snapshot ages from its successor: T0+1d was live
|
||||
// until T0+2d, which sits exactly on the cutoff (kept); only T0 is over the count.
|
||||
expect(removed.length).toBe(1);
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([
|
||||
T0 + 4 * DAY,
|
||||
T0 + 3 * DAY,
|
||||
T0 + 2 * DAY,
|
||||
T0 + DAY,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the last good copy when the first change comes after a quiet month', () => {
|
||||
put('settings.json', '{"theme":"good"}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'startup', now: T0 });
|
||||
put('settings.json', '{}'); // the bad write, 40 days later
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 40 * DAY });
|
||||
pruneConfigBackups(backups, { keepCount: 20, keepDays: 30, now: T0 + 40 * DAY });
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 40 * DAY, T0]);
|
||||
});
|
||||
|
||||
it('never deletes a folder it did not make', () => {
|
||||
makeN(3);
|
||||
mkdirSync(join(backups, 'photos'));
|
||||
writeFileSync(join(backups, 'photos', 'a.jpg'), 'x');
|
||||
mkdirSync(join(backups, 'cfg-20200101-000000')); // right name, no manifest: not ours
|
||||
writeFileSync(join(backups, 'notes.txt'), 'keep');
|
||||
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 500 * DAY });
|
||||
expect(existsSync(join(backups, 'photos', 'a.jpg'))).toBe(true);
|
||||
expect(existsSync(join(backups, 'cfg-20200101-000000'))).toBe(true);
|
||||
expect(existsSync(join(backups, 'notes.txt'))).toBe(true);
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing for a folder that does not exist', () => {
|
||||
expect(pruneConfigBackups(join(root, 'nope'), { keepCount: 1, keepDays: 1 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreConfigBackup', () => {
|
||||
it('puts the files back, atomically, after taking a pre-restore snapshot', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
put('clis.json', '{"clis":{"grok":{"enabled":true}}}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
|
||||
put('settings.json', '{"polluted":true}');
|
||||
rmSync(join(data, 'clis.json'));
|
||||
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
|
||||
expect(r.restored.sort()).toEqual(['clis.json', 'settings.json']);
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"good":true}');
|
||||
expect(readFileSync(join(data, 'clis.json'), 'utf8')).toBe('{"clis":{"grok":{"enabled":true}}}');
|
||||
expect(statSync(join(data, 'settings.json')).mode & 0o777).toBe(0o600);
|
||||
|
||||
// The state it replaced is recoverable.
|
||||
expect(r.safetySnapshot).toBeTruthy();
|
||||
const safety = listConfigBackups(backups).find((b) => b.id === r.safetySnapshot)!;
|
||||
expect(safety.reason).toBe('pre-restore');
|
||||
expect(readFileSync(join(backups, safety.id, 'settings.json'), 'utf8')).toBe('{"polluted":true}');
|
||||
});
|
||||
|
||||
it('refuses a snapshot whose file no longer matches its checksum, changing nothing', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
chmodSync(join(backups, made.id, 'settings.json'), 0o600);
|
||||
writeFileSync(join(backups, made.id, 'settings.json'), '{"tampered":true}');
|
||||
put('settings.json', '{"live":true}');
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).toThrow(
|
||||
ConfigBackupCorruptError
|
||||
);
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"live":true}');
|
||||
expect(listConfigBackups(backups)).toHaveLength(1); // no pre-restore snapshot for a refused restore
|
||||
});
|
||||
|
||||
it('rejects unknown ids and anything that is not a snapshot name (no path traversal)', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
for (const id of ['cfg-19990101-000000', '../data', '..', 'photos', 'cfg-20261001-120000/../../x']) {
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id }), id).toThrow(
|
||||
ConfigBackupNotFoundError
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the newest backup as the undo copy when the live files already equal it', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('settings.json', '{"a":2}');
|
||||
const newest = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + DAY }) as {
|
||||
id: string;
|
||||
};
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: first.id, now: T0 + 2 * DAY });
|
||||
expect(r.safetySnapshot).toBe(newest.id);
|
||||
expect(listConfigBackups(backups).map((b) => b.id)).toEqual([newest.id, first.id]); // no duplicate
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('a file it cannot write does not stop the others, and the error names both lists', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
put('webhook.json', '{"enabled":false}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('settings.json', '{"polluted":true}');
|
||||
put('webhook.json', '{"enabled":true}');
|
||||
rmSync(join(data, 'clis.json'));
|
||||
mkdirSync(join(data, 'clis.json')); // between the other two in the tracked list
|
||||
let caught: unknown;
|
||||
try {
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ConfigBackupPartialRestoreError);
|
||||
const err = caught as ConfigBackupPartialRestoreError;
|
||||
expect(err.restored).toEqual(['settings.json', 'webhook.json']);
|
||||
expect(err.failed.map((f) => f.name)).toEqual(['clis.json']);
|
||||
expect(err.safetySnapshot).toBeTruthy();
|
||||
expect(readFileSync(join(data, 'webhook.json'), 'utf8')).toBe('{"enabled":false}');
|
||||
expect(readdirSync(data).filter((n) => n.includes('.restore-'))).toEqual([]); // no temp left behind
|
||||
});
|
||||
|
||||
it('only ever writes allowlisted names, even if a manifest lists others', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
const mpath = join(backups, made.id, 'manifest.json');
|
||||
const m = JSON.parse(readFileSync(mpath, 'utf8'));
|
||||
m.files.push({ name: '../escape.json', bytes: 1, sha256: 'x' }, { name: 'state.json', bytes: 1, sha256: 'x' });
|
||||
writeFileSync(mpath, JSON.stringify(m));
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id });
|
||||
expect(existsSync(join(root, 'escape.json'))).toBe(false);
|
||||
expect(existsSync(join(data, 'state.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffConfigBackups', () => {
|
||||
const snap = (now: number) => {
|
||||
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 't', force: true, now });
|
||||
if (r.status !== 'created') throw new Error('expected a snapshot');
|
||||
return r.id;
|
||||
};
|
||||
const diff = (from: string, to: string) => diffConfigBackups({ dataDir: data, backupDir: backups, from, to });
|
||||
const file = (d: ReturnType<typeof diff>, name: string) => d.files.find((f) => f.name === name)!;
|
||||
|
||||
it('lists key-level changes between two backups', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'a', theme: { mode: 'dark' }, tags: ['x'] }));
|
||||
const a = snap(T0);
|
||||
put('settings.json', JSON.stringify({ displayName: 'b', theme: { mode: 'dark' }, tags: ['x', 'y'], extra: 1 }));
|
||||
const b = snap(T0 + 1000);
|
||||
const f = file(diff(a, b), 'settings.json');
|
||||
expect(f.status).toBe('changed');
|
||||
expect(f.changes).toEqual([
|
||||
{ path: 'displayName', kind: 'changed', before: '"a"', after: '"b"' },
|
||||
{ path: 'extra', kind: 'added', after: '1' },
|
||||
{ path: 'tags[1]', kind: 'added', after: '"y"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('compares a backup with the running config, and reports files that appeared or vanished', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('webhook.json', '{"url":"u"}');
|
||||
const a = snap(T0);
|
||||
put('settings.json', '{"a":2}');
|
||||
rmSync(join(data, 'webhook.json'));
|
||||
put('intents.json', '{"i":true}');
|
||||
const d = diff(a, 'current');
|
||||
expect(d.changed).toBe(3);
|
||||
expect(file(d, 'settings.json').changes).toEqual([{ path: 'a', kind: 'changed', before: '1', after: '2' }]);
|
||||
expect(file(d, 'webhook.json').status).toBe('removed');
|
||||
expect(file(d, 'intents.json').status).toBe('added');
|
||||
expect(diff(a, a).changed).toBe(0);
|
||||
});
|
||||
|
||||
it('never returns a webhook URL, captured prompts, or URL userinfo and query', () => {
|
||||
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/oldsecret' }));
|
||||
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw1'] }] }));
|
||||
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t1#f' }));
|
||||
const a = snap(T0);
|
||||
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/newsecret' }));
|
||||
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw2'] }] }));
|
||||
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t2#f' }));
|
||||
const d = diff(a, 'current');
|
||||
const json = JSON.stringify(d);
|
||||
for (const s of ['secret', 'pw1', 'pw2', 'user:pw', 'token=', 'hooks.slack']) expect(json).not.toContain(s);
|
||||
expect(file(d, 'webhook.json').changes.every((c) => c.redacted)).toBe(true);
|
||||
expect(file(d, 'intents.json').changes.every((c) => c.redacted)).toBe(true);
|
||||
});
|
||||
|
||||
it('never returns a credential value, only that it differs', () => {
|
||||
put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n');
|
||||
put('users.json', JSON.stringify({ admin: { name: 'root' } }));
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-old', url: 'http://x' }]));
|
||||
const a = snap(T0);
|
||||
put('.env', 'SECRET_ONE=hunter3\nOTHER=abd\n');
|
||||
put('users.json', JSON.stringify({ admin: { name: 'rooted' } }));
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-new', url: 'http://y' }]));
|
||||
const d = diff(a, 'current');
|
||||
const json = JSON.stringify(d);
|
||||
for (const secret of ['hunter', 'abc', 'abd', 'root', 'sk-old', 'sk-new']) expect(json).not.toContain(secret);
|
||||
expect(file(d, '.env').changes.every((c) => c.redacted)).toBe(true);
|
||||
expect(file(d, 'custom-model-hosts.json').changes).toEqual([
|
||||
{ path: '[0].apiKey', kind: 'changed', redacted: true },
|
||||
{ path: '[0].url', kind: 'changed', before: '"http://x"', after: '"http://y"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not count a self-refreshed discovery timestamp as a difference', () => {
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 1 }]));
|
||||
const a = snap(T0);
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 2 }]));
|
||||
const d = diff(a, 'current');
|
||||
expect(file(d, 'custom-model-hosts.json').status).toBe('unchanged');
|
||||
expect(d.changed).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a file that is not JSON as differing, without its content', () => {
|
||||
put('settings.json', 'not json at all');
|
||||
const a = snap(T0);
|
||||
put('settings.json', 'still not json');
|
||||
const f = file(diff(a, 'current'), 'settings.json');
|
||||
expect(f).toMatchObject({ status: 'changed', detail: 'bytes', changes: [] });
|
||||
});
|
||||
|
||||
it('caps the changes listed per file', () => {
|
||||
put('settings.json', '{}');
|
||||
const a = snap(T0);
|
||||
put('settings.json', JSON.stringify(Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`k${i}`, i]))));
|
||||
const f = file(diff(a, 'current'), 'settings.json');
|
||||
expect(f.changes).toHaveLength(200);
|
||||
expect(f.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses unknown ids, path traversal and a tampered snapshot', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const a = snap(T0);
|
||||
expect(() => diff('cfg-20200101-000000', 'current')).toThrow(ConfigBackupNotFoundError);
|
||||
expect(() => diff('../etc', 'current')).toThrow(ConfigBackupNotFoundError);
|
||||
writeFileSync(join(backups, a, 'settings.json'), '{"a":99}');
|
||||
expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConfigBackup: a snapshot dated ahead of the clock', () => {
|
||||
it('is not written again on every pass once the clock is corrected', () => {
|
||||
put('settings.json', '{"v":0}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put('settings.json', '{"v":1}');
|
||||
const ahead = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2 * 3_600_000 });
|
||||
expect(ahead.status).toBe('created');
|
||||
put('settings.json', '{"v":2}'); // the clock is back at T0 + 1 minute
|
||||
const corrected = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 60_000 });
|
||||
expect(corrected.status).toBe('created');
|
||||
const [newest, second] = listConfigBackups(backups);
|
||||
expect(newest.id).toBe((corrected as { id: string }).id); // dated no earlier than the one before it
|
||||
expect(newest.createdAt).toBeGreaterThan(second.createdAt);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const r = createConfigBackup({
|
||||
dataDir: data,
|
||||
backupDir: backups,
|
||||
reason: 'auto',
|
||||
now: T0 + 120_000 + i * 300_000,
|
||||
});
|
||||
expect(r.status, `pass ${i}`).toBe('unchanged');
|
||||
}
|
||||
expect(listConfigBackups(backups)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instances sharing one backup folder', () => {
|
||||
it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => {
|
||||
const other = join(root, 'other-data');
|
||||
mkdirSync(other, { recursive: true });
|
||||
put('settings.json', '{"a":1}');
|
||||
writeFileSync(join(other, 'settings.json'), '{"b":2}');
|
||||
const mine = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
const theirs = createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(theirs.status).toBe('created');
|
||||
// Each side's change check compares with its own newest snapshot, so a second tick is a no-op.
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'unchanged'
|
||||
);
|
||||
expect(createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 3000 }).status).toBe(
|
||||
'unchanged'
|
||||
);
|
||||
expect(listConfigBackups(backups, configBackupOrigin(data))).toHaveLength(1);
|
||||
expect(
|
||||
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 999 * DAY, origin: configBackupOrigin(data) })
|
||||
).toEqual([]);
|
||||
expect(listConfigBackups(backups)).toHaveLength(2);
|
||||
const theirId = (theirs as { id: string }).id;
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: theirId })).toThrow(
|
||||
ConfigBackupNotFoundError
|
||||
);
|
||||
expect((mine as { id: string }).id).not.toBe(theirId);
|
||||
});
|
||||
|
||||
it('a restore reports tracked files it left alone, and sweeps only old temp folders', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('webhook.json', '{"url":"u"}');
|
||||
expect(restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }).untouched).toEqual(['webhook.json']);
|
||||
const stale = join(backups, '.tmp-cfg-20200101-000000-99999');
|
||||
mkdirSync(stale);
|
||||
sweepConfigBackupTemps(backups, Date.now() + 2 * 3_600_000);
|
||||
expect(existsSync(stale)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore trusts only what this module wrote', () => {
|
||||
const made = () =>
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
|
||||
it('refuses a symlinked snapshot file and a snapshot file with group or world bits', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
const { id } = made();
|
||||
const file = join(backups, id, 'clis.json');
|
||||
chmodSync(file, 0o644);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
|
||||
|
||||
chmodSync(file, 0o600);
|
||||
const real = join(root, 'elsewhere.json');
|
||||
writeFileSync(real, '{"clis":{}}');
|
||||
rmSync(file);
|
||||
symlinkSync(real, file);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(/symlink/);
|
||||
// nothing was written and no pre-restore copy was taken for a refused restore
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips the group/world bit test on a filesystem that does not keep modes, but never the symlink test', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
const { id } = made();
|
||||
const file = join(backups, id, 'clis.json');
|
||||
chmodSync(file, 0o644); // what vfat, CIFS and WSL /mnt/d report for every file
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).not.toThrow();
|
||||
|
||||
const real = join(root, 'elsewhere.json');
|
||||
writeFileSync(real, '{"clis":{}}');
|
||||
rmSync(file);
|
||||
symlinkSync(real, file);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/);
|
||||
});
|
||||
|
||||
it('the mode probe never writes through a planted symlink or trips over a planted directory', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
const target = join(root, 'victim.txt');
|
||||
writeFileSync(target, 'precious', { mode: 0o644 });
|
||||
symlinkSync(target, join(backups, `.mode-probe-${process.pid}`));
|
||||
mkdirSync(join(backups, '.mode-probe-0'));
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
|
||||
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).not.toThrow();
|
||||
expect(readFileSync(target, 'utf8')).toBe('precious');
|
||||
expect(statSync(target).mode & 0o777).toBe(0o644);
|
||||
});
|
||||
|
||||
it('compare applies the same check to a snapshot file', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
chmodSync(join(backups, id, 'settings.json'), 0o644);
|
||||
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).toThrow(
|
||||
ConfigBackupCorruptError
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a snapshot folder that others can write to', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
chmodSync(join(backups, id), 0o777);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
|
||||
chmodSync(join(backups, id), 0o700);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the backup retention when the live settings.json was reset and has none of the four keys', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'old', configBackupKeepCount: 100, configBackupKeepDays: 0 }));
|
||||
const { id } = made();
|
||||
put('settings.json', '{}');
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
|
||||
expect(JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'))).toEqual({
|
||||
displayName: 'old',
|
||||
configBackupKeepCount: 100,
|
||||
configBackupKeepDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('latestBackupRetention skips a newer snapshot that recorded none, and a pre-restore copy records it', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0, keepCount: 100, keepDays: 0 });
|
||||
put('settings.json', '{"a":2}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); // an older build
|
||||
const origin = configBackupOrigin(data);
|
||||
expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 });
|
||||
const id = listConfigBackups(backups, origin).at(-1)!.id;
|
||||
// A change since the newest backup, so the restore makes a pre-restore copy rather than reusing it.
|
||||
put('settings.json', '{"a":3}');
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 });
|
||||
expect(r.safetySnapshot).toBeTruthy();
|
||||
const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!;
|
||||
expect(pre.reason).toBe('pre-restore');
|
||||
expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({
|
||||
keepCount: 100,
|
||||
keepDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current folder and retention when settings.json is restored', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 }));
|
||||
const { id } = made();
|
||||
put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 }));
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
|
||||
const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'));
|
||||
// the folder and days follow the live file; the live file has no keep count (reset), so the backup's 5 stays
|
||||
expect(after).toEqual({
|
||||
displayName: 'old',
|
||||
configBackupDir: '/new',
|
||||
configBackupKeepCount: 5,
|
||||
configBackupKeepDays: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview A backup pass reads the backup folder once: one directory listing and one read per
|
||||
* manifest, shared by the recorded-retention fallback, the change check and the prune. Each used to
|
||||
* list the folder itself, which on a network folder meant every manifest read three times every five
|
||||
* minutes (found in review of #661).
|
||||
*
|
||||
* ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (no server).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const rec = vi.hoisted(() => ({ dir: '', calls: [] as string[] }));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F =>
|
||||
((path: unknown, ...rest: unknown[]) => {
|
||||
if (rec.dir && String(path).startsWith(rec.dir)) rec.calls.push(`${name} ${String(path)}`);
|
||||
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
|
||||
}) as unknown as F;
|
||||
const wrapped = {
|
||||
...actual,
|
||||
readdirSync: record('readdirSync', actual.readdirSync),
|
||||
readFileSync: record('readFileSync', actual.readFileSync),
|
||||
};
|
||||
return { ...wrapped, default: wrapped };
|
||||
});
|
||||
|
||||
import { 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';
|
||||
|
||||
const dir = () => defaultConfigBackupDir(getDataDir());
|
||||
const DAY = 86_400_000;
|
||||
const BASE = Date.UTC(2025, 0, 1);
|
||||
|
||||
beforeEach(() => {
|
||||
rec.dir = '';
|
||||
rec.calls.length = 0;
|
||||
rmSync(dir(), { recursive: true, force: true });
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
rec.dir = '';
|
||||
rmSync(dir(), { recursive: true, force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
});
|
||||
|
||||
describe('one backup pass', () => {
|
||||
it('lists the folder once and reads each manifest once', async () => {
|
||||
// Only the OLDEST snapshot recorded a retention, so the fallback has to look through every one.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: `v${i}` }));
|
||||
createConfigBackup({
|
||||
dataDir: getDataDir(),
|
||||
backupDir: dir(),
|
||||
reason: 'auto',
|
||||
now: BASE + i * DAY,
|
||||
...(i === 0 && { keepCount: 100, keepDays: 0 }),
|
||||
});
|
||||
}
|
||||
// A change (so the pass writes a snapshot and then prunes) and no retention keys (the fallback reads).
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'changed' }));
|
||||
rec.dir = dir();
|
||||
const out = await runConfigBackup('auto');
|
||||
rec.dir = '';
|
||||
|
||||
expect(out?.result.status).toBe('created');
|
||||
expect(out?.removed).toEqual([]); // the recorded 100 / no age limit, not the defaults
|
||||
expect(rec.calls.filter((c) => c.startsWith('readdirSync'))).toHaveLength(1);
|
||||
const manifestReads = rec.calls.filter((c) => c.startsWith('readFileSync') && c.endsWith('manifest.json'));
|
||||
expect(manifestReads).toHaveLength(6);
|
||||
expect(new Set(manifestReads).size).toBe(6);
|
||||
expect(listConfigBackups(dir())).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('counts the snapshot it just made when it prunes', async () => {
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 0 }));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: i }));
|
||||
createConfigBackup({ dataDir: getDataDir(), backupDir: dir(), reason: 'auto', now: BASE + i * DAY });
|
||||
}
|
||||
const before = listConfigBackups(dir()).map((b) => b.id);
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 9 }));
|
||||
const out = await runConfigBackup('auto');
|
||||
expect(out?.result.status).toBe('created');
|
||||
const kept = listConfigBackups(dir()).map((b) => b.id);
|
||||
// keep 2: the new one and the newest before it; the two older ones go
|
||||
expect(kept).toEqual([(out?.result as { id: string }).id, before[0]]);
|
||||
expect(out?.removed.sort()).toEqual([before[1], before[2]].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @fileoverview A custom backup folder that stops answering (a network mount) must never be listed or
|
||||
* read before the bounded probe says it is there: GET, Back up now, diff and restore answer 503 without
|
||||
* touching it.
|
||||
* With only `configBackupDir` in settings.json the recorded-retention fallback would otherwise
|
||||
* `readdirSync` the folder first (found in review of #661).
|
||||
*
|
||||
* ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const dead = vi.hoisted(() => ({ dir: '', touched: [] as string[] }));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F =>
|
||||
((path: unknown, ...rest: unknown[]) => {
|
||||
if (dead.dir && String(path).startsWith(dead.dir)) dead.touched.push(`${name} ${String(path)}`);
|
||||
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
|
||||
}) as unknown as F;
|
||||
const wrapped = {
|
||||
...actual,
|
||||
readdirSync: record('readdirSync', actual.readdirSync),
|
||||
readFileSync: record('readFileSync', actual.readFileSync),
|
||||
statSync: record('statSync', actual.statSync),
|
||||
lstatSync: record('lstatSync', actual.lstatSync),
|
||||
existsSync: record('existsSync', actual.existsSync),
|
||||
};
|
||||
return { ...wrapped, default: wrapped };
|
||||
});
|
||||
|
||||
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
|
||||
return {
|
||||
...actual,
|
||||
probePathKind: async (path: string, options?: { pastCap?: boolean }) =>
|
||||
dead.dir && path.startsWith(dead.dir) ? 'unknown' : actual.probePathKind(path, options),
|
||||
};
|
||||
});
|
||||
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerConfigBackupRoutes } 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 { configBackupOrigin } from '../../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'cfg-backup-dead-'));
|
||||
dead.dir = join(root, 'mount');
|
||||
dead.touched.length = 0;
|
||||
configBackupOrigin(getDataDir()); // an instance that already has an id: the fallback has something to look for
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
// Only the folder: no retention keys, the shape that makes the recorded-retention fallback want to read it.
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dead.dir }));
|
||||
});
|
||||
afterEach(() => {
|
||||
dead.dir = '';
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
});
|
||||
|
||||
describe('a custom backup folder that does not answer', () => {
|
||||
it.each([
|
||||
['GET', '/api/config-backups'],
|
||||
['POST', '/api/config-backups'],
|
||||
['GET', '/api/config-backups/diff?from=cfg-20261001-120000'],
|
||||
['POST', '/api/config-backups/cfg-20261001-120000/restore'],
|
||||
] as const)('%s %s answers 503 without reading the folder', async (method, url) => {
|
||||
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
|
||||
const res = await app.inject({ method, url });
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(dead.touched).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user