mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(config-backup): ignore a web tab's lastOpenedAt, audit multi-user restores, refuse symlinked or loosened snapshots, keep the folder settings on restore
This commit is contained in:
1 parent
71fcefbaf2
commit
c5f1e99036
5 files changed
+146
-6
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
@@ -148,6 +149,22 @@ describe('createConfigBackup', () => {
|
||||
});
|
||||
|
||||
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}` }]),
|
||||
@@ -486,3 +503,44 @@ describe('instances sharing one backup folder', () => {
|
||||
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('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 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'));
|
||||
expect(after).toEqual({ displayName: 'old', configBackupDir: '/new', configBackupKeepDays: 7 });
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,22 @@ describe('/api/config-backups', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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, {
|
||||
|
||||
Reference in new issue
Block a user