Files
HIPCTF2/tests/backend/database-init-recovery.spec.ts
T

170 lines
7.4 KiB
TypeScript

process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import { ConfigService } from '@nestjs/config';
// ConfigService reads process.env with precedence over its internal
// overrides, so unset the env-driven defaults before constructing it
// for these tests so the per-test data roots are honored.
delete process.env.DATABASE_PATH;
delete process.env.UPLOAD_DIR;
delete process.env.SYSTEM_OP_STAGING_DIR;
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DatabaseInitService } from '../../backend/src/database/database-init.service';
import {
FilesystemTransactionService,
TransactionManifest,
} from '../../backend/src/modules/admin/system/filesystem-transaction.service';
import { ERROR_CODES } from '../../backend/src/common/errors/error-codes';
function makeRoot(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
describe('Job 919: DatabaseInitService recovery on startup', () => {
let work: string;
let dbPath: string;
let uploadDir: string;
let stagingDir: string;
beforeEach(() => {
work = makeRoot('hipctf-job919-init-');
dbPath = path.join(work, 'db.sqlite');
uploadDir = path.join(work, 'uploads');
stagingDir = path.join(work, '.system-staging');
// Note: `uploadDir` is intentionally NOT created here. Individual tests
// arrange the filesystem in the exact on-disk shape they want to
// exercise (e.g. an interrupted-swap manifest expects the live uploads
// dir to NOT exist on disk because the in-process snapshot has moved it).
});
afterEach(() => {
if (work && fs.existsSync(work)) {
try { fs.rmSync(work, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function buildInit(): DatabaseInitService {
const config = new ConfigService({
DATABASE_PATH: dbPath,
UPLOAD_DIR: uploadDir,
SYSTEM_OP_STAGING_DIR: stagingDir,
} as any);
const fsTx = new FilesystemTransactionService();
// The DataSource is not used by the recovery code path, so a stub suffices.
const fakeDataSource: any = { isInitialized: true, query: async () => [] };
const init = new DatabaseInitService(fakeDataSource, config, fsTx);
return init;
}
it('restores the old database + uploads generation from an interrupted restore manifest', async () => {
const stamp = `${Date.now()}-11111`;
const liveDb = dbPath;
const liveUploads = uploadDir;
const oldDbRollback = `${liveDb}.rollback-${stamp}`;
const oldUpRollback = `${liveUploads}.rollback-${stamp}`;
const stagedDb = path.join(stagingDir, 'unused.db');
const stagedUploads = path.join(stagingDir, 'unused.up');
// Simulate the in-process state AFTER the live-snapshot pair rename has
// moved the live tree to the rollback location but BEFORE the staged
// replacement has been moved into place.
fs.writeFileSync(oldDbRollback, 'OLD-DB-SNAPSHOT');
fs.mkdirSync(oldUpRollback, { recursive: true });
fs.writeFileSync(path.join(oldUpRollback, 'keep.txt'), 'OLD-UPLOAD-SNAPSHOT');
const id = `${Date.now()}-tyy`;
const txDir = path.join(stagingDir, id);
fs.mkdirSync(txDir, { recursive: true });
const manifest: TransactionManifest = {
schemaVersion: 1,
transactionId: id,
operation: 'restore-backup',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
phase: 'live-snapshotted',
pairs: [
{ livePath: liveDb, rollbackPath: oldDbRollback, stagedPath: stagedDb, contributesToGeneration: true, sidecarSuffixes: ['-wal', '-shm'] },
{ livePath: liveUploads, rollbackPath: oldUpRollback, stagedPath: stagedUploads, contributesToGeneration: true, sidecarSuffixes: [] },
],
};
fs.writeFileSync(path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME), JSON.stringify(manifest));
const init = buildInit();
await (init as any).recoverInterruptedSystemTransactions();
expect(fs.readFileSync(liveDb, 'utf8')).toBe('OLD-DB-SNAPSHOT');
expect(fs.readFileSync(path.join(liveUploads, 'keep.txt'), 'utf8')).toBe('OLD-UPLOAD-SNAPSHOT');
expect(fs.existsSync(path.join(liveUploads, 'replaced.txt'))).toBe(false);
expect(fs.existsSync(oldDbRollback)).toBe(false);
expect(fs.existsSync(oldUpRollback)).toBe(false);
expect(fs.existsSync(txDir)).toBe(false);
});
it('fails closed (throws) when an interrupted manifest has a hybrid state (no full old or new generation)', async () => {
const stamp = `${Date.now()}-22222`;
const oldDbRollback = `${dbPath}.rollback-${stamp}`;
fs.writeFileSync(oldDbRollback, 'OLD-DB-SNAPSHOT');
fs.mkdirSync(uploadDir, { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'replaced.txt'), 'NEW-ORPHAN');
const oldUpRollback = `${uploadDir}.rollback-${stamp}`;
fs.mkdirSync(oldUpRollback, { recursive: true });
fs.writeFileSync(path.join(oldUpRollback, 'keep.txt'), 'OLD-UPLOAD-SNAPSHOT');
const id = `${Date.now()}-hyb`;
const txDir = path.join(stagingDir, id);
fs.mkdirSync(txDir, { recursive: true });
const manifest: TransactionManifest = {
schemaVersion: 1,
transactionId: id,
operation: 'restore-backup',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
phase: 'verified',
pairs: [
{ livePath: dbPath, rollbackPath: oldDbRollback, stagedPath: path.join(stagingDir, 'unused.db'), contributesToGeneration: true, sidecarSuffixes: [] },
{ livePath: uploadDir, rollbackPath: oldUpRollback, stagedPath: path.join(stagingDir, 'unused.up'), contributesToGeneration: true, sidecarSuffixes: [] },
],
};
fs.writeFileSync(path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME), JSON.stringify(manifest));
const init = buildInit();
await expect((init as any).recoverInterruptedSystemTransactions()).rejects.toMatchObject({
code: ERROR_CODES.SYSTEM_RESTORE_FAILED,
});
expect(fs.readFileSync(oldDbRollback, 'utf8')).toBe('OLD-DB-SNAPSHOT');
expect(fs.readFileSync(path.join(uploadDir, 'replaced.txt'), 'utf8')).toBe('NEW-ORPHAN');
expect(fs.readFileSync(path.join(oldUpRollback, 'keep.txt'), 'utf8')).toBe('OLD-UPLOAD-SNAPSHOT');
expect(fs.existsSync(txDir)).toBe(true);
});
it('removes stale unowned rollback/restore-stage/wipe-stage artifacts during startup sweep', async () => {
const now = Date.now();
const oldStamp = now - 2 * 24 * 60 * 60_000; // 2 days old
const staleDbRollback = path.join(work, `.db.sqlite.rollback-${oldStamp}-abc`);
const staleUploadsRollback = `${uploadDir}.rollback-${oldStamp}-def`;
const staleUploadsStage = `${uploadDir}.restore-stage-${oldStamp}-ghi`;
fs.writeFileSync(staleDbRollback, 'orphan');
fs.mkdirSync(staleUploadsRollback, { recursive: true });
fs.mkdirSync(staleUploadsStage, { recursive: true });
const oldTime = new Date(oldStamp);
fs.utimesSync(staleDbRollback, oldTime, oldTime);
fs.utimesSync(staleUploadsRollback, oldTime, oldTime);
fs.utimesSync(staleUploadsStage, oldTime, oldTime);
const init = buildInit();
await (init as any).recoverInterruptedSystemTransactions();
expect(fs.existsSync(staleDbRollback)).toBe(false);
expect(fs.existsSync(staleUploadsRollback)).toBe(false);
expect(fs.existsSync(staleUploadsStage)).toBe(false);
});
});