mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
fix(config-backup): redact webhook and intents, bounded folder probe, per-instance origin, reload intents on restore
Review round on #661: webhook.json and intents.json are redacted in compare and URL values lose userinfo/query; a custom folder is probed (bounded, pastCap) before each pass and in each route and only created when its parent exists; manifests record an origin and change check, prune, list, restore and diff are scoped to it; a restore reloads IntentStore, lists untouched files and treats settings.json as restart-required; remote-cases.json is tracked; stale .tmp-cfg-* folders are swept and a failed restore unlinks its temp; the group is hidden from non-admins; an empty days field saves 30.
This commit is contained in:
1 parent
a2e49eb26d
commit
247740fc77
8 files changed
+240
-37
No files matched your search
@@ -30,6 +30,8 @@ import {
|
||||
readConfigBackupSettings,
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
configBackupOrigin,
|
||||
sweepConfigBackupTemps,
|
||||
} from '../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
@@ -335,6 +337,21 @@ describe('diffConfigBackups', () => {
|
||||
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' } }));
|
||||
@@ -388,3 +405,43 @@ describe('diffConfigBackups', () => {
|
||||
expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-r
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import { defaultConfigBackupDir } from '../../src/config-backup.js';
|
||||
import { intentStore } from '../../src/intent-store.js';
|
||||
|
||||
let custom: string;
|
||||
const settings = (extra: Record<string, unknown> = {}) => {
|
||||
@@ -84,11 +85,35 @@ describe('/api/config-backups', () => {
|
||||
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('settings.json');
|
||||
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 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']) {
|
||||
|
||||
Reference in new issue
Block a user