feat: Admin Area System: Database Backup/Restore and Danger Zone 1.02

This commit is contained in:
OpenVelo Agent
2026-07-23 15:49:12 +00:00
parent 958b004452
commit 274db7c845
12 changed files with 1350 additions and 129 deletions
@@ -356,3 +356,113 @@ describe('Job 918: Restore commit succeeds end-to-end with a real backup file',
fs.rmSync(work, { recursive: true, force: true });
});
});
describe('Job 919: Restore commit survives a kill-9 mid-swap without leaving a hybrid on-disk state', () => {
// The reported Job 919 incident: a process kill during commit produced
// a hybrid where the live DB reverted to the old generation but the live
// uploads contained the new generation's file (and a rollback directory
// remained on disk). After the fix, a subsequent startup must clean
// the orphan rollback directory and bring the uploads back in line with
// the database.
beforeEach(() => {
jest.resetModules();
delete process.env.SYSTEM_OP_RESTORE_STAGE_TTL_MS;
delete process.env.SYSTEM_OP_CONFIRM_TOKEN_TTL_MS;
delete process.env.SYSTEM_OP_STAGING_DIR;
delete process.env.UPLOAD_DIR;
delete process.env.DATABASE_PATH;
});
it('simulated kill after the first pair swap leaves a manifest that startup recovery restores to a single coherent generation', async () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-job919-'));
const dataDir = path.join(work, 'data');
const uploadDir = path.join(dataDir, 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
const dbPath = path.join(dataDir, 'db.sqlite');
// Live state: OLD DB, OLD upload (forensics icon), with a stale rollback
// directory left behind by the prior broken swap attempt.
const DatabaseLib = require('better-sqlite3');
const live = new DatabaseLib(dbPath);
live.exec(`
CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT);
CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO "user" (id, username, role) VALUES ('admin-live', 'admin', 'admin');
INSERT INTO "setting" (key, value) VALUES ('site_title', 'OLD');
`);
live.close();
fs.writeFileSync(path.join(uploadDir, 'old-icon.png'), Buffer.from('OLD-ICON'));
const oldIconBytes = Buffer.from('OLD-ICON');
// Simulate the in-progress snapshot has already moved the live trees
// into the rollback locations, but the staged replacements never reached
// the live path. Place the OLD uploads content in the rollback dir.
const stamp = `${Date.now()}-sim`;
const oldDbRollback = path.join(dataDir, `.db.sqlite.rollback-${stamp}`);
const oldUpRollback = `${uploadDir}.rollback-${stamp}`;
fs.copyFileSync(dbPath, oldDbRollback);
fs.rmSync(dbPath, { recursive: true, force: true });
fs.mkdirSync(oldUpRollback, { recursive: true });
fs.copyFileSync(path.join(uploadDir, 'old-icon.png'), path.join(oldUpRollback, 'old-icon.png'));
fs.rmSync(uploadDir, { recursive: true, force: true });
const id = `${Date.now()}-kr`;
const stagingDir = path.join(dataDir, '.system-staging');
fs.mkdirSync(stagingDir, { recursive: true });
const txDir = path.join(stagingDir, id);
fs.mkdirSync(txDir, { recursive: true });
const manifest = {
schemaVersion: 1,
transactionId: id,
operation: 'restore-backup',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
phase: 'live-snapshotted',
pairs: [
{ livePath: dbPath, rollbackPath: oldDbRollback, stagedPath: path.join(stagingDir, 'unused.db'), contributesToGeneration: true, sidecarSuffixes: ['-wal', '-shm'] },
{ livePath: uploadDir, rollbackPath: oldUpRollback, stagedPath: path.join(stagingDir, 'unused.up'), contributesToGeneration: true, sidecarSuffixes: [] },
],
};
fs.writeFileSync(path.join(txDir, 'manifest.json'), JSON.stringify(manifest));
process.env.DATABASE_PATH = dbPath;
process.env.UPLOAD_DIR = uploadDir;
process.env.SYSTEM_OP_STAGING_DIR = stagingDir;
const { ConfigService } = require('@nestjs/config');
const config = new ConfigService({
DATABASE_PATH: dbPath,
UPLOAD_DIR: uploadDir,
SYSTEM_OP_STAGING_DIR: stagingDir,
SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000,
SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000,
BODY_SIZE_LIMIT: '50mb',
} as any);
const { DatabaseInitService } = require('../../backend/src/database/database-init.service');
const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service');
const fakeDataSource = { isInitialized: true, query: async () => [] };
const init = new DatabaseInitService(fakeDataSource, config, new FilesystemTransactionService());
await (init as any).recoverInterruptedSystemTransactions();
// Coherent pre-restore state on disk: DB and uploads agree on the OLD
// generation; the rollback artifacts and the manifest are gone.
expect(fs.existsSync(dbPath)).toBe(true);
const reopened = new Database(dbPath, { readonly: true });
expect(reopened.prepare('SELECT value FROM "setting" WHERE key=?').get('site_title').value).toBe('OLD');
expect(reopened.prepare('SELECT COUNT(*) AS c FROM "user"').get().c).toBe(1);
reopened.close();
expect(fs.readFileSync(path.join(uploadDir, 'old-icon.png'))).toEqual(oldIconBytes);
expect(fs.existsSync(oldDbRollback)).toBe(false);
expect(fs.existsSync(oldUpRollback)).toBe(false);
expect(fs.existsSync(txDir)).toBe(false);
try { fs.rmSync(work, { recursive: true, force: true }); } catch { /* ignore */ }
});
});