fix(config-backup): probe a custom folder before the retention fallback reads it, test that Save omits a displayed-only retention

This commit is contained in:
Devvyn committed 2026-10-20 21:47:20 +08:00
1 parent 9c0fc30927
commit 8a4ff775fb
9 files changed
+173 -25

No files matched your search

+46
View File
@@ -106,3 +106,49 @@ describe('loadConfigBackups (retention fields)', () => {
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,76 @@
/**
* @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, 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'],
['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([]);
});
});