joplock/tests/backupService.test.js

197 lines
7.8 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { EventEmitter } = require('events');
const { PassThrough } = require('stream');
const { createBackupService, ensureWithinDir, timestampForFile } = require('../app/backupService');
const makeChild = ({ exitCode = 0, stdoutText = '', stderrText = '' } = {}) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
process.nextTick(() => {
if (stdoutText) child.stdout.write(stdoutText);
child.stdout.end();
if (stderrText) child.stderr.write(stderrText);
child.stderr.end();
child.emit('close', exitCode);
});
return child;
};
test('ensureWithinDir rejects invalid backup names', () => {
assert.throws(() => ensureWithinDir('/tmp/backups', '../evil.dump'));
assert.throws(() => ensureWithinDir('/tmp/backups', 'evil.sql'));
});
test('timestampForFile produces filesystem-safe timestamp', () => {
assert.equal(timestampForFile(Date.UTC(2026, 4, 18, 14, 22, 31)), '2026-05-18T14-22-31Z');
});
test('backup service lists backups newest first', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
const oldFile = path.join(dir, 'joplock-backup-older.dump');
const newFile = path.join(dir, 'joplock-backup-newer.dump');
fs.writeFileSync(oldFile, 'one');
fs.writeFileSync(newFile, 'two');
const older = new Date('2026-05-18T14:00:00Z');
const newer = new Date('2026-05-18T15:00:00Z');
fs.utimesSync(oldFile, older, older);
fs.utimesSync(newFile, newer, newer);
const service = createBackupService({ backupDir: dir, postgresConfig: {} });
const backups = await service.listBackups();
assert.equal(backups[0].name, 'joplock-backup-newer.dump');
assert.equal(backups[1].name, 'joplock-backup-older.dump');
});
test('backup service starts backup job via pg_dump and completes in background', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
now: () => Date.UTC(2026, 4, 18, 14, 22, 31),
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild({ stdoutText: 'dump-bytes' });
},
});
const backup = await service.startBackupJob();
assert.equal(spawnArgs.cmd, 'pg_dump');
assert.deepEqual(spawnArgs.args, ['--format=custom', '--compress=zstd:19', '--no-owner', '--no-privileges', '--dbname', 'joplin']);
assert.equal(backup.fileName, 'joplock-backup-2026-05-18T14-22-31Z.dump');
await service.waitForIdle();
assert.ok(fs.existsSync(path.join(dir, backup.fileName)));
assert.equal(service.currentStatus().state, 'completed');
});
test('backup service clamps compression level for gzip fallback', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
compression: '',
compressionLevel: 42,
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild({ stdoutText: 'dump-bytes' });
},
});
await service.startBackupJob();
assert.ok(spawnArgs.args.includes('--compress=gzip:9'));
});
test('backup service uses explicit compression spec when provided', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
compression: 'gzip:1',
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild({ stdoutText: 'dump-bytes' });
},
});
await service.startBackupJob();
assert.ok(spawnArgs.args.includes('--compress=gzip:1'));
});
test('backup service can create uncompressed backups per run', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
compression: 'zstd:19',
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild({ stdoutText: 'dump-bytes' });
},
});
await service.startBackupJob({ mode: 'uncompressed' });
assert.ok(spawnArgs.args.includes('--compress=none'));
});
test('backup service supports explicit zstd compression mode', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
compression: 'gzip:9',
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild({ stdoutText: 'dump-bytes' });
},
});
await service.startBackupJob({ mode: 'zstd' });
assert.ok(spawnArgs.args.includes('--compress=zstd:3'));
});
test('backup service supports balanced gzip compression mode', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
compression: 'zstd:19',
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild({ stdoutText: 'dump-bytes' });
},
});
await service.startBackupJob({ mode: 'balanced' });
assert.ok(spawnArgs.args.includes('--compress=zstd:3'));
});
test('backup service supports fast gzip compression mode', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
compression: 'zstd:19',
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild({ stdoutText: 'dump-bytes' });
},
});
await service.startBackupJob({ mode: 'fast' });
assert.ok(spawnArgs.args.includes('--compress=gzip:1'));
});
test('backup service starts restore job via pg_restore and completes in background', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
fs.writeFileSync(path.join(dir, 'joplock-backup-2026.dump'), 'x');
let spawnArgs = null;
const service = createBackupService({
backupDir: dir,
postgresConfig: { database: 'joplin', host: 'db', port: 5432, user: 'joplin', password: 'secret' },
spawnImpl: (cmd, args, options) => {
spawnArgs = { cmd, args, options };
return makeChild();
},
});
await service.startRestoreJob('joplock-backup-2026.dump');
await service.waitForIdle();
assert.equal(spawnArgs.cmd, 'pg_restore');
assert.ok(spawnArgs.args.includes('--clean'));
assert.ok(spawnArgs.args.includes('--single-transaction'));
assert.ok(spawnArgs.args.includes(path.join(dir, 'joplock-backup-2026.dump')));
assert.equal(service.currentStatus().state, 'completed');
});
test('backup service deletes an existing backup file', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'joplock-backups-'));
const file = path.join(dir, 'joplock-backup-2026.dump');
fs.writeFileSync(file, 'x');
const service = createBackupService({ backupDir: dir, postgresConfig: {} });
const deleted = await service.deleteBackup('joplock-backup-2026.dump');
assert.equal(deleted.name, 'joplock-backup-2026.dump');
assert.equal(fs.existsSync(file), false);
});