fix(config-backup): origin is a stored random id, Settings refills after a settings restore, clis.json reloads

Review round 2 on #661: the hostname in the origin orphaned every snapshot when a Docker container was recreated, so the origin is a random id kept in <data dir>/config-backup-id; after restoring settings.json the Settings form is re-read from /api/settings so Save cannot write the old values back; the restore confirm names the files and the credential ones; clis.json is reloaded (reloadCliRegistry) and users.json needs no restart; API reference and route docs corrected.
This commit is contained in:
Devvyn committed 2026-10-20 18:01:42 +08:00
1 parent 247740fc77
commit 8b27e4beb6
10 files changed
+186 -23

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
/**
* @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);
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
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* @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) {
const calls: string[] = [];
const confirms: 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: true,
json: async () => ({ success: true, data: { restored, restartRequired: [] } }),
}),
loadAppSettingsFromServer: async () => void calls.push('reload-settings'),
openAppSettings: () => void calls.push('refill-form'),
loadConfigBackups: () => void calls.push('reload-list'),
showToast: () => {},
});
return { app, calls, confirms };
}
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('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\)/);
});
});
+1 -1
View File
@@ -84,7 +84,7 @@ describe('/api/config-backups', () => {
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.restored).toEqual(expect.arrayContaining(['settings.json', 'clis.json']));
expect(d.restartRequired).toContain('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');