mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
backup / restoreslop
This commit is contained in:
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user