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); }); });