See CHANGELOG.md for the full v0.16.0 entry. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
127 lines
4.7 KiB
TypeScript
127 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
default: {
|
|
copyFile: vi.fn(async () => undefined),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
rename: vi.fn(),
|
|
mkdir: vi.fn(),
|
|
access: vi.fn(),
|
|
readdir: vi.fn(async () => []),
|
|
unlink: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import fs from 'fs/promises';
|
|
import { restoreBackup } from '@/lib/admin/yaml-service';
|
|
|
|
/**
|
|
* restoreBackup joins its argument into a path and copies the result over a
|
|
* content file, whose contents the admin GET endpoints then return. Before this
|
|
* guard, `path.join` resolved `..` segments, so any readable file on the host
|
|
* could be written over content/settings.yaml and read back.
|
|
*
|
|
* The function has no callers yet — this pins the guard for whoever wires up the
|
|
* restore button the admin panel does not have.
|
|
*/
|
|
describe('restoreBackup rejects anything that is not a backup it produced', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
const rejected = [
|
|
'../../../../etc/passwd',
|
|
'../gallery.yaml',
|
|
'.backups/../../gallery.yaml',
|
|
'/etc/passwd',
|
|
'gallery.yaml', // the live file, not a backup
|
|
'gallery.yaml.2026-05-31T17-30-00-000Z.bak.txt',
|
|
'evil-gallery.yaml.2026-05-31T17-30-00-000Z.bak',
|
|
'about.yaml.2026-05-31T17-30-00-000Z.bak',
|
|
'trip.md.2026-05-31T17-30-00-000Z.bak', // journal backups go through the journal service
|
|
'',
|
|
];
|
|
|
|
for (const name of rejected) {
|
|
it(`refuses ${JSON.stringify(name)}`, async () => {
|
|
await expect(restoreBackup(name)).rejects.toThrow(/unrecognised backup name/i);
|
|
expect(fs.copyFile).not.toHaveBeenCalled();
|
|
});
|
|
}
|
|
|
|
// A traversal that still ended in a plausible-looking name would have picked
|
|
// the destination too, via the old includes('gallery.yaml') check.
|
|
it('refuses a traversal that embeds a valid-looking name', async () => {
|
|
await expect(restoreBackup('../../gallery.yaml.2026-01-01T00-00-00-000Z.bak')).rejects.toThrow(
|
|
/unrecognised backup name/i,
|
|
);
|
|
expect(fs.copyFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('restoreBackup accepts the names it writes', () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
// The restore itself goes through atomicWrite (writeFile the temp file,
|
|
// then rename it onto the target) rather than fs.copyFile, so a failure
|
|
// partway through cannot truncate the live file (#630). fs.copyFile is
|
|
// still used, but only for the pre-restore safety snapshot.
|
|
it('restores a normal backup', async () => {
|
|
await expect(
|
|
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
|
|
).resolves.toBeUndefined();
|
|
expect(fs.copyFile).toHaveBeenCalled(); // pre-restore snapshot
|
|
expect(fs.rename).toHaveBeenCalled(); // atomic restore write
|
|
});
|
|
|
|
it('restores an about.md backup onto content/about.md', async () => {
|
|
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
|
|
|
|
const calls = vi.mocked(fs.rename).mock.calls;
|
|
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
|
|
});
|
|
|
|
it('restores a pre-restore backup', async () => {
|
|
await expect(
|
|
restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.pre-restore.bak'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('derives the destination from the matched name, not a substring search', async () => {
|
|
await restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.bak');
|
|
|
|
const calls = vi.mocked(fs.rename).mock.calls;
|
|
expect(String(calls[calls.length - 1][1])).toMatch(/content\/settings\.yaml$/);
|
|
});
|
|
|
|
it('reads the backup before touching the pre-restore snapshot or the live file', async () => {
|
|
await restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak');
|
|
|
|
const readOrder = vi.mocked(fs.readFile).mock.invocationCallOrder[0];
|
|
const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder[0];
|
|
expect(readOrder).toBeLessThan(copyOrder);
|
|
});
|
|
|
|
it('aborts without touching the live file when the safety snapshot fails for a reason other than ENOENT', async () => {
|
|
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
|
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
|
);
|
|
|
|
await expect(restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak')).rejects.toThrow(
|
|
'EACCES',
|
|
);
|
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
|
expect(fs.rename).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('proceeds when the safety snapshot fails only because there is no current file yet', async () => {
|
|
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
|
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
|
);
|
|
|
|
await expect(
|
|
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
|
|
).resolves.toBeUndefined();
|
|
expect(fs.rename).toHaveBeenCalled();
|
|
});
|
|
});
|