backup / restoreslop

This commit is contained in:
legop3
2026-09-14 19:09:02 -04:00
parent edc1b825f2
commit 1c34849bd0
32 changed files with 1220 additions and 70 deletions
@@ -0,0 +1,135 @@
// Backup and Restore Service Tests
// Purpose: Verifies complete archive round trips, exclusions, malicious entry rejection, startup replacement, and rollback.
// Scope: Uses one isolated SERVER_DATA_DIR and injected SQLite owners; it never reads or changes development server data.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const tar = require('tar');
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-backup-restore-'));
process.env.SERVER_DATA_DIR = temporaryRoot;
const { createFullBackup } = require('./backup');
const { inspectArchive, prepareRestoreArchive, validateExtractedRestore } = require('./restore');
const startupRestore = require('./startupRestore');
const VALID_RESTORE_ID = 'a'.repeat(64);
function createSourceDatabase(name) {
const filePath = path.join(temporaryRoot, `source-${name}`);
const database = new Database(filePath);
database.exec('CREATE TABLE example (value TEXT NOT NULL); INSERT INTO example VALUES (\'preserved\');');
if (name === 'configuration.sqlite') {
database.exec('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); INSERT INTO schema_migrations VALUES (1);');
} else if (name === 'identity.sqlite') {
database.pragma('user_version = 4');
}
return database;
}
test.after(() => {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
});
test('creates and validates a complete backup while excluding runtime and control data', async () => {
await fsp.writeFile(path.join(temporaryRoot, 'state.json'), '{"preserved":true}\n');
await fsp.mkdir(path.join(temporaryRoot, 'replays'), { recursive: true });
await fsp.writeFile(path.join(temporaryRoot, 'replays', 'complete.mp4'), 'complete replay');
await fsp.mkdir(path.join(temporaryRoot, 'runtime'), { recursive: true });
await fsp.writeFile(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard me');
const sources = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite']
.map(createSourceDatabase);
const owners = sources.map((database) => ({
backupDatabase: (destinationPath) => database.backup(destinationPath),
}));
const result = await createFullBackup({
configurationDatabase: owners[0],
identityService: owners[1],
fleetReportService: owners[2],
jobId: 'test-backup',
});
assert.ok(fs.statSync(result.archivePath).size > 0);
assert.ok(result.manifest.files.some((entry) => entry.path === 'state.json'));
assert.ok(result.manifest.files.some((entry) => entry.path === 'replays/complete.mp4'));
assert.equal(result.manifest.files.some((entry) => entry.path.includes('runtime')), false);
assert.equal(result.manifest.files.some((entry) => entry.path.includes('backup-restore')), false);
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
await fsp.mkdir(restoreJob, { recursive: true });
const uploadedArchive = path.join(restoreJob, 'upload.tar.gz');
await fsp.copyFile(result.archivePath, uploadedArchive);
const summary = await prepareRestoreArchive({ archivePath: uploadedArchive, jobDir: restoreJob, actor: 'test' });
assert.equal(summary.fileCount, result.manifest.files.length);
sources.forEach((database) => database.close());
});
test('rejects a staged restore whose contents no longer match the manifest', async () => {
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
const statePath = path.join(restoreJob, 'extracted', 'data', 'state.json');
await fsp.writeFile(statePath, '{"tampered":true}\n');
await assert.rejects(validateExtractedRestore(path.join(restoreJob, 'extracted')), /checksum or size mismatch/);
// Restore the known source content so the following startup-application test
// continues to exercise a genuinely validated replacement payload.
await fsp.writeFile(statePath, '{"preserved":true}\n');
});
test('rejects symbolic links before extracting an archive', async () => {
const unsafeRoot = path.join(temporaryRoot, 'unsafe-archive');
await fsp.mkdir(path.join(unsafeRoot, 'data'), { recursive: true });
await fsp.writeFile(path.join(unsafeRoot, 'manifest.json'), '{}');
await fsp.symlink('/etc/passwd', path.join(unsafeRoot, 'data', 'escape'));
const archivePath = path.join(temporaryRoot, 'unsafe.tar.gz');
await tar.c({ cwd: unsafeRoot, file: archivePath, gzip: true }, ['manifest.json', 'data']);
await assert.rejects(inspectArchive(archivePath), /unsupported entry type/);
});
test('applies validated replacement data and removes rollback only after startup succeeds', () => {
const restoreId = VALID_RESTORE_ID;
const jobDir = path.join(temporaryRoot, 'backup-restore', `restore-${restoreId}`);
fs.writeFileSync(path.join(temporaryRoot, 'old-state.txt'), 'old');
fs.mkdirSync(path.join(temporaryRoot, 'runtime'), { recursive: true });
fs.writeFileSync(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard during restore');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId,
state: 'pending',
requestedAt: Date.now(),
actor: 'test',
});
const applied = startupRestore.applyPendingRestore();
assert.equal(applied.status, 'awaiting-health');
assert.equal(fs.existsSync(path.join(temporaryRoot, 'old-state.txt')), false);
assert.equal(fs.existsSync(path.join(temporaryRoot, 'runtime')), false);
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), '{"preserved":true}\n');
assert.equal(fs.existsSync(path.join(temporaryRoot, 'backup-restore', 'rollback', 'old-state.txt')), true);
const completed = startupRestore.markStartupSuccessful();
assert.equal(completed.status, 'restored');
assert.equal(fs.existsSync(jobDir), false);
assert.equal(fs.existsSync(startupRestore.pendingPath), false);
});
test('restores the rollback copy when a replaced application did not reach health', () => {
const restoreId = 'b'.repeat(64);
const rollbackDir = path.join(temporaryRoot, 'backup-restore', 'rollback');
fs.mkdirSync(rollbackDir, { recursive: true });
fs.writeFileSync(path.join(rollbackDir, 'state.json'), 'previous state');
fs.writeFileSync(path.join(temporaryRoot, 'state.json'), 'failed replacement');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId,
state: 'awaiting-health',
requestedAt: Date.now(),
actor: 'test',
});
const result = startupRestore.applyPendingRestore();
assert.equal(result.status, 'rolled-back');
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), 'previous state');
assert.equal(startupRestore.getLastRestoreResult().status, 'rolled-back');
});