286 lines
12 KiB
JavaScript
286 lines
12 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
const test = require('node:test');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const os = require('node:os');
|
||
|
|
const path = require('node:path');
|
||
|
|
|
||
|
|
// ── Test environment ─────────────────────────────────────────────────────────
|
||
|
|
// Set env vars before any require so modules pick up the right paths.
|
||
|
|
const testId = `backup-cleanup-test-${process.pid}`;
|
||
|
|
const dbPath = path.join(os.tmpdir(), `${testId}.sqlite`);
|
||
|
|
const backupPath = path.join(os.tmpdir(), `${testId}-backups`);
|
||
|
|
|
||
|
|
process.env.DB_PATH = dbPath;
|
||
|
|
process.env.BACKUP_PATH = backupPath;
|
||
|
|
|
||
|
|
const { closeDb, setSetting } = require('../db/database');
|
||
|
|
const {
|
||
|
|
BACKUP_DIR,
|
||
|
|
createBackup,
|
||
|
|
deleteBackup,
|
||
|
|
listBackups,
|
||
|
|
applyRetention,
|
||
|
|
importBackupBuffer,
|
||
|
|
checksumFile,
|
||
|
|
} = require('../services/backupService');
|
||
|
|
const {
|
||
|
|
validateScheduleSettings,
|
||
|
|
computeNextRun,
|
||
|
|
} = require('../services/backupScheduler');
|
||
|
|
const {
|
||
|
|
pruneOrphanedBackupPartials,
|
||
|
|
pruneStaleExportFiles,
|
||
|
|
} = require('../services/cleanupService');
|
||
|
|
|
||
|
|
// ── Teardown ─────────────────────────────────────────────────────────────────
|
||
|
|
test.after(() => {
|
||
|
|
closeDb();
|
||
|
|
try { fs.unlinkSync(dbPath); } catch {}
|
||
|
|
try { fs.rmSync(backupPath, { recursive: true, force: true }); } catch {}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// backupService
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
test('BACKUP_DIR resolves to the BACKUP_PATH env var', () => {
|
||
|
|
assert.equal(BACKUP_DIR, path.resolve(backupPath));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('createBackup writes a valid .sqlite file and returns metadata', async () => {
|
||
|
|
const meta = await createBackup('bill-tracker-backup');
|
||
|
|
assert.ok(meta.id, 'has id');
|
||
|
|
assert.ok(meta.filename.endsWith('.sqlite'));
|
||
|
|
assert.equal(meta.type, 'manual');
|
||
|
|
assert.ok(meta.size_bytes > 0);
|
||
|
|
assert.match(meta.checksum, /^[a-f0-9]{64}$/);
|
||
|
|
assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id)));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('listBackups returns the created backup sorted newest-first', async () => {
|
||
|
|
const list = listBackups();
|
||
|
|
assert.ok(list.length >= 1);
|
||
|
|
assert.ok(list[0].id.startsWith('bill-tracker-backup-') || list[0].id.startsWith('scheduled-backup-'));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('deleteBackup removes the file', async () => {
|
||
|
|
const meta = await createBackup('bill-tracker-backup');
|
||
|
|
const filePath = path.join(BACKUP_DIR, meta.id);
|
||
|
|
assert.ok(fs.existsSync(filePath));
|
||
|
|
deleteBackup(meta.id);
|
||
|
|
assert.ok(!fs.existsSync(filePath));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('applyRetention keeps only the requested number of backups', async () => {
|
||
|
|
// Raise retention so createBackup's own internal pruning doesn't interfere.
|
||
|
|
setSetting('backup_schedule_retention_count', '100');
|
||
|
|
|
||
|
|
for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }
|
||
|
|
|
||
|
|
await createBackup('bill-tracker-backup');
|
||
|
|
await createBackup('bill-tracker-backup');
|
||
|
|
await createBackup('bill-tracker-backup');
|
||
|
|
assert.equal(listBackups().length, 3);
|
||
|
|
|
||
|
|
const { deleted } = applyRetention(1);
|
||
|
|
assert.equal(deleted.length, 2);
|
||
|
|
assert.equal(listBackups().length, 1);
|
||
|
|
|
||
|
|
setSetting('backup_schedule_retention_count', '2');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('importBackupBuffer accepts a valid SQLite buffer', async () => {
|
||
|
|
// Create a real backup, then re-import its bytes
|
||
|
|
const src = await createBackup('bill-tracker-backup');
|
||
|
|
const buf = fs.readFileSync(path.join(BACKUP_DIR, src.id));
|
||
|
|
const meta = await importBackupBuffer(buf);
|
||
|
|
assert.equal(meta.type, 'imported');
|
||
|
|
assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id)));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('importBackupBuffer rejects non-SQLite data', async () => {
|
||
|
|
const buf = Buffer.from('this is not a sqlite database at all');
|
||
|
|
await assert.rejects(
|
||
|
|
() => importBackupBuffer(buf),
|
||
|
|
err => {
|
||
|
|
assert.ok(err.status === 400);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('importBackupBuffer rejects a checksum mismatch', async () => {
|
||
|
|
const src = await createBackup('bill-tracker-backup');
|
||
|
|
const buf = fs.readFileSync(path.join(BACKUP_DIR, src.id));
|
||
|
|
await assert.rejects(
|
||
|
|
() => importBackupBuffer(buf, { expectedChecksum: 'a'.repeat(64) }),
|
||
|
|
err => {
|
||
|
|
assert.ok(err.status === 400);
|
||
|
|
assert.match(err.message, /checksum/i);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('checksumFile returns a 64-char hex string', async () => {
|
||
|
|
const src = await createBackup('bill-tracker-backup');
|
||
|
|
const sum = checksumFile(path.join(BACKUP_DIR, src.id));
|
||
|
|
assert.match(sum, /^[a-f0-9]{64}$/);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// backupScheduler
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
test('validateScheduleSettings accepts valid daily settings', () => {
|
||
|
|
const s = validateScheduleSettings({ enabled: true, frequency: 'daily', time: '03:30', retention_count: '7' });
|
||
|
|
assert.equal(s.enabled, true);
|
||
|
|
assert.equal(s.frequency, 'daily');
|
||
|
|
assert.equal(s.time, '03:30');
|
||
|
|
assert.equal(s.retention_count, 7);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('validateScheduleSettings rejects bad frequency', () => {
|
||
|
|
assert.throws(
|
||
|
|
() => validateScheduleSettings({ enabled: true, frequency: 'hourly', time: '02:00', retention_count: '2' }),
|
||
|
|
/frequency/i
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('validateScheduleSettings rejects bad time format', () => {
|
||
|
|
assert.throws(
|
||
|
|
() => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '25:00', retention_count: '2' }),
|
||
|
|
/time/i
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('validateScheduleSettings rejects retention_count out of range', () => {
|
||
|
|
assert.throws(
|
||
|
|
() => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '02:00', retention_count: '0' }),
|
||
|
|
/retention/i
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('computeNextRun returns null when disabled', () => {
|
||
|
|
const result = computeNextRun({ enabled: false, frequency: 'daily', time: '02:00' });
|
||
|
|
assert.equal(result, null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('computeNextRun returns a future ISO string when enabled', () => {
|
||
|
|
const from = new Date('2026-05-29T10:00:00Z');
|
||
|
|
const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from);
|
||
|
|
assert.ok(typeof result === 'string');
|
||
|
|
assert.ok(new Date(result) > from);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('computeNextRun advances to next day when time has already passed today', () => {
|
||
|
|
const from = new Date('2026-05-29T15:00:00Z'); // 3 PM UTC
|
||
|
|
const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from);
|
||
|
|
const next = new Date(result);
|
||
|
|
// Next run should be 2 AM the following day, so > 24h away
|
||
|
|
assert.ok(next.getTime() - from.getTime() > 12 * 60 * 60 * 1000);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// cleanupService — pruneOrphanedBackupPartials
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
test('pruneOrphanedBackupPartials removes .partial files older than 2 hours', () => {
|
||
|
|
fs.mkdirSync(backupPath, { recursive: true });
|
||
|
|
|
||
|
|
const oldPartial = path.join(backupPath, 'old.partial');
|
||
|
|
const newPartial = path.join(backupPath, 'new.partial');
|
||
|
|
|
||
|
|
fs.writeFileSync(oldPartial, 'data');
|
||
|
|
fs.writeFileSync(newPartial, 'data');
|
||
|
|
|
||
|
|
// Back-date the old file's mtime by 3 hours
|
||
|
|
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||
|
|
fs.utimesSync(oldPartial, old, old);
|
||
|
|
|
||
|
|
const result = pruneOrphanedBackupPartials(backupPath);
|
||
|
|
assert.equal(result.removed, 1);
|
||
|
|
assert.equal(result.errors, 0);
|
||
|
|
assert.ok(!fs.existsSync(oldPartial));
|
||
|
|
assert.ok(fs.existsSync(newPartial));
|
||
|
|
|
||
|
|
// cleanup
|
||
|
|
try { fs.unlinkSync(newPartial); } catch {}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneOrphanedBackupPartials is a no-op for a missing directory', () => {
|
||
|
|
const result = pruneOrphanedBackupPartials('/nonexistent/path');
|
||
|
|
assert.equal(result.removed, 0);
|
||
|
|
assert.equal(result.errors, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// cleanupService — pruneStaleExportFiles
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
test('pruneStaleExportFiles removes old .sqlite export files', () => {
|
||
|
|
const tmpDir = os.tmpdir();
|
||
|
|
const oldFile = path.join(tmpDir, `bill-tracker-user-1-${Date.now()}.sqlite`);
|
||
|
|
const newFile = path.join(tmpDir, `bill-tracker-user-2-${Date.now()}.sqlite`);
|
||
|
|
|
||
|
|
fs.writeFileSync(oldFile, 'fake');
|
||
|
|
fs.writeFileSync(newFile, 'fake');
|
||
|
|
|
||
|
|
const old = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 h ago
|
||
|
|
fs.utimesSync(oldFile, old, old);
|
||
|
|
|
||
|
|
const result = pruneStaleExportFiles(2);
|
||
|
|
assert.ok(result.removed >= 1);
|
||
|
|
assert.ok(!fs.existsSync(oldFile));
|
||
|
|
|
||
|
|
try { fs.unlinkSync(newFile); } catch {}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleExportFiles removes .xlsx files older than 24 hours', () => {
|
||
|
|
const tmpDir = os.tmpdir();
|
||
|
|
const oldXlsx = path.join(tmpDir, `bill-tracker-user-3-${Date.now()}.xlsx`);
|
||
|
|
const newXlsx = path.join(tmpDir, `bill-tracker-user-4-${Date.now()}.xlsx`);
|
||
|
|
|
||
|
|
fs.writeFileSync(oldXlsx, 'fake');
|
||
|
|
fs.writeFileSync(newXlsx, 'fake');
|
||
|
|
|
||
|
|
const old = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 h ago
|
||
|
|
fs.utimesSync(oldXlsx, old, old);
|
||
|
|
|
||
|
|
const result = pruneStaleExportFiles(2);
|
||
|
|
assert.ok(result.removed >= 1);
|
||
|
|
assert.ok(!fs.existsSync(oldXlsx));
|
||
|
|
|
||
|
|
try { fs.unlinkSync(newXlsx); } catch {}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleExportFiles does not remove .xlsx files younger than 24 hours', () => {
|
||
|
|
const tmpDir = os.tmpdir();
|
||
|
|
const recentXlsx = path.join(tmpDir, `bill-tracker-user-5-${Date.now()}.xlsx`);
|
||
|
|
|
||
|
|
fs.writeFileSync(recentXlsx, 'fake');
|
||
|
|
// mtime stays at now — well within 24 h
|
||
|
|
|
||
|
|
const result = pruneStaleExportFiles(2);
|
||
|
|
assert.ok(fs.existsSync(recentXlsx));
|
||
|
|
|
||
|
|
try { fs.unlinkSync(recentXlsx); } catch {}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('pruneStaleExportFiles ignores non-bill-tracker files in tmpdir', () => {
|
||
|
|
const tmpDir = os.tmpdir();
|
||
|
|
const other = path.join(tmpDir, `some-other-tool-export-${Date.now()}.sqlite`);
|
||
|
|
|
||
|
|
fs.writeFileSync(other, 'fake');
|
||
|
|
const old = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||
|
|
fs.utimesSync(other, old, old);
|
||
|
|
|
||
|
|
const result = pruneStaleExportFiles(2);
|
||
|
|
assert.ok(fs.existsSync(other), 'should not have deleted unrelated file');
|
||
|
|
assert.equal(result.errors, 0);
|
||
|
|
|
||
|
|
try { fs.unlinkSync(other); } catch {}
|
||
|
|
});
|