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 { ApiError } from '../../backend/src/common/errors/api-error'; import { ERROR_CODES } from '../../backend/src/common/errors/error-codes'; import type { ArgumentMetadata } from '@nestjs/common'; import { AdminSystemController } from '../../backend/src/modules/admin/system/admin-system.controller'; import { ZodValidationPipe } from '../../backend/src/common/pipes/zod-validation.pipe'; import { AdminRestoreCommitBodySchema } from '../../backend/src/modules/admin/system/dto/admin-system.dto'; describe('Job 916: Admin restore commit body passes stagingId through', () => { function makeController(opts: { commitRestore: jest.Mock; consume?: jest.Mock; }) { const consume = opts.consume ?? jest.fn().mockResolvedValue({ id: 'tok' }); const reauthenticateAdmin = jest.fn().mockResolvedValue({ id: 'admin-1' }); const revokeAllRefreshSessions = jest.fn().mockResolvedValue(undefined); const issue = jest.fn().mockResolvedValue({ token: 't', expiresAt: 'x', id: 'i' }); const resetScores = jest.fn(); const wipeChallenges = jest.fn(); return { controller: new AdminSystemController( { build: jest.fn() } as any, { stageArchive: jest.fn(), commitRestore: opts.commitRestore } as any, { resetScores, wipeChallenges } as any, { consume, issue } as any, { reauthenticateAdmin, revokeAllRefreshSessions } as any, ), consume, commitRestore: opts.commitRestore, issue, reauthenticateAdmin, revokeAllRefreshSessions, }; } function adminReq(): any { return { user: { sub: 'admin-1', role: 'admin' } }; } it('forwards stagingId to RestoreService.commitRestore and returns ok', async () => { const commitRestore = jest.fn().mockResolvedValue({ restoresPerformed: true, revokeUserId: 'admin-1' }); const { controller, consume, revokeAllRefreshSessions } = makeController({ commitRestore }); const result = await controller.commitRestore( adminReq(), { operation: 'restore-backup', token: 'raw-token', stagingId: 'stage-xyz' }, ); expect(result).toEqual({ ok: true, operation: 'restore-backup' }); expect(consume).toHaveBeenCalledWith({ userId: 'admin-1', operation: 'restore-backup', token: 'raw-token', stagingId: 'stage-xyz', }); expect(commitRestore).toHaveBeenCalledWith('stage-xyz'); expect(revokeAllRefreshSessions).toHaveBeenCalledWith('admin-1'); }); it('returns SYSTEM_RESTORE_STAGE_EXPIRED when stagingId is missing', async () => { const commitRestore = jest.fn(); const consume = jest.fn(); const { controller } = makeController({ commitRestore, consume }); await expect( controller.commitRestore( adminReq(), { operation: 'restore-backup', token: 'raw-token' }, ), ).rejects.toMatchObject({ code: ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED, }); expect(consume).not.toHaveBeenCalled(); expect(commitRestore).not.toHaveBeenCalled(); }); it('returns SYSTEM_TOKEN_MISMATCH when operation is not restore-backup', async () => { const commitRestore = jest.fn(); const consume = jest.fn(); const { controller } = makeController({ commitRestore, consume }); await expect( controller.commitRestore( adminReq(), { operation: 'reset-scores' as any, token: 'raw-token', stagingId: 'stage-xyz' }, ), ).rejects.toMatchObject({ code: ERROR_CODES.SYSTEM_TOKEN_MISMATCH, }); expect(consume).not.toHaveBeenCalled(); expect(commitRestore).not.toHaveBeenCalled(); }); it('AdminRestoreCommitBodySchema preserves the stagingId field', () => { const pipe = new ZodValidationPipe(AdminRestoreCommitBodySchema); const meta: ArgumentMetadata = { type: 'body' }; const value = pipe.transform({ operation: 'restore-backup', token: 'raw-token', stagingId: 'stage-xyz', }, meta); expect(value).toEqual({ operation: 'restore-backup', token: 'raw-token', stagingId: 'stage-xyz', }); }); it('AdminRestoreCommitBodySchema keeps optional stagingId absent (not empty-stringed)', () => { const pipe = new ZodValidationPipe(AdminRestoreCommitBodySchema); const meta: ArgumentMetadata = { type: 'body' }; const value = pipe.transform({ operation: 'restore-backup', token: 'raw-token' }, meta); expect(value).toEqual({ operation: 'restore-backup', token: 'raw-token' }); expect((value as any).stagingId).toBeUndefined(); void ApiError; }); }); describe('Job 918: Restore commit succeeds end-to-end with a real backup file', () => { // Minimal valid archive shape used to drive RestoreService.stageArchive + commitRestore // against a real file-backed SQLite database and uploads directory. 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; }); async function buildArchive(adminId: string, challengeCount: number, blogCount: number) { const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); const tmp = fs.mkdtempSync(require('path').join(require('os').tmpdir(), 'hipctf-archive-')); const dbPath = path.join(tmp, 'source.sqlite'); const db = new Database(dbPath); db.exec(` CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT); CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT); CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT); CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user" FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user" FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; `); db.prepare(`INSERT INTO "user" (id, username, role, passwordHash, status) VALUES (?, ?, 'admin', 'hash', 'enabled')`).run(adminId, 'admin'); db.prepare(`INSERT INTO "setting" (key, value) VALUES (?, ?)`).run('site_title', 'ARCHIVE'); for (let i = 0; i < challengeCount; i++) { db.prepare(`INSERT INTO "challenge" (id, title) VALUES (?, ?)`).run(`c${i}`, `Archived ${i}`); } for (let i = 0; i < blogCount; i++) { db.prepare(`INSERT INTO "blog_post" (id, title) VALUES (?, ?)`).run(`b${i}`, `Archived blog ${i}`); } const tables = { user: db.prepare('SELECT * FROM "user"').all(), setting: db.prepare('SELECT * FROM "setting"').all(), challenge: db.prepare('SELECT * FROM "challenge"').all(), blog_post: db.prepare('SELECT * FROM "blog_post"').all(), }; const uploads = [ { path: 'icons/replaced.png', originalFilename: 'replaced.png', mimeType: 'image/png', sizeBytes: 4, sha256: '', dataBase64: Buffer.from('ARCH').toString('base64'), }, ]; uploads[0].sha256 = require('crypto').createHash('sha256').update(Buffer.from('ARCH')).digest('hex'); db.close(); const archive = { format: 'hipctf-system-backup', version: 1, exportedAt: new Date().toISOString(), tables, tableCounts: { user: tables.user.length, setting: tables.setting.length, challenge: tables.challenge.length, blog_post: tables.blog_post.length, }, uploads, }; return { archiveText: JSON.stringify(archive), cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) }; } function makeLiveDbWithTrigger(dbPath: string): void { const fs = require('fs'); fs.rmSync(dbPath, { recursive: true, force: true }); const Database = require('better-sqlite3'); const db = new Database(dbPath); db.exec(` CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT); CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT); CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT); INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('admin-live', 'admin', 'admin', 'hash', 'enabled'); INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('player-live', 'p1', 'player', 'hash', 'enabled'); INSERT INTO "setting" (key, value) VALUES ('site_title', 'MODIFIED TITLE'); INSERT INTO "challenge" (id, title) VALUES ('live-1', 'Live 1'); INSERT INTO "blog_post" (id, title) VALUES ('live-b1', 'Live blog'); CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user" FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user" FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; `); db.close(); } it('successfully swaps live DB and uploads for an archive that contains the live admin', 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-job918-')); const dataDir = path.join(work, 'data'); const uploadDir = path.join(dataDir, 'uploads'); fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(path.join(uploadDir, 'icons'), { recursive: true }); fs.writeFileSync(path.join(uploadDir, 'icons', 'old.png'), Buffer.from('OLD')); const dbPath = path.join(dataDir, 'db.sqlite'); makeLiveDbWithTrigger(dbPath); process.env.DATABASE_PATH = dbPath; process.env.UPLOAD_DIR = uploadDir; process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging'); const { ConfigService } = require('@nestjs/config'); const config = new ConfigService({ DATABASE_PATH: dbPath, UPLOAD_DIR: uploadDir, SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'), SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000, SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000, BODY_SIZE_LIMIT: '50mb', }); const ttl = config.get('SYSTEM_OP_RESTORE_STAGE_TTL_MS'); expect(typeof ttl).toBe('number'); const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service'); const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service'); const fakeDataSource = { query: jest.fn().mockResolvedValue([{ c: 1 }]), }; const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService()); const { archiveText, cleanup } = await buildArchive('admin-live', 3, 2); const staged = await restore.stageArchive(archiveText, 'admin-live'); expect(staged.stagingId).toBeTruthy(); const result = await restore.commitRestore(staged.stagingId); expect(result.restoresPerformed).toBe(true); const liveDb = new Database(dbPath, { readonly: true }); const counts = { user: liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c, challenge: liveDb.prepare(`SELECT COUNT(*) AS c FROM "challenge"`).get().c, blog_post: liveDb.prepare(`SELECT COUNT(*) AS c FROM "blog_post"`).get().c, }; const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value; const adminCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE role='admin'`).get().c; const triggerNames = liveDb .prepare(`SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'trg_user_last_admin_%'`) .all() .map((r: any) => r.name) .sort(); liveDb.close(); expect(counts).toEqual({ user: 1, challenge: 3, blog_post: 2 }); expect(title).toBe('ARCHIVE'); expect(adminCount).toBe(1); expect(triggerNames).toEqual(['trg_user_last_admin_delete', 'trg_user_last_admin_update']); expect(fs.existsSync(path.join(uploadDir, 'icons', 'replaced.png'))).toBe(true); expect(fs.existsSync(path.join(uploadDir, 'icons', 'old.png'))).toBe(false); expect(fs.readFileSync(path.join(uploadDir, 'icons', 'replaced.png')).toString()).toBe('ARCH'); cleanup(); fs.rmSync(work, { recursive: true, force: true }); }); it('rejects an archive with no admin user without swapping live data', 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-job918-noadmin-')); const dataDir = path.join(work, 'data'); const uploadDir = path.join(dataDir, 'uploads'); fs.mkdirSync(uploadDir, { recursive: true }); fs.writeFileSync(path.join(uploadDir, 'keep.txt'), 'KEEP'); const dbPath = path.join(dataDir, 'db.sqlite'); makeLiveDbWithTrigger(dbPath); process.env.DATABASE_PATH = dbPath; process.env.UPLOAD_DIR = uploadDir; process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging'); const { ConfigService } = require('@nestjs/config'); const config = new ConfigService({ DATABASE_PATH: dbPath, UPLOAD_DIR: uploadDir, SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'), SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000, SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000, BODY_SIZE_LIMIT: '50mb', }); const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service'); const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service'); const fakeDataSource = { query: jest.fn().mockResolvedValue([{ c: 1 }]) }; const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService()); const Database2 = require('better-sqlite3'); const db = new Database2(':memory:'); db.exec(`CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);`); const archive = { format: 'hipctf-system-backup', version: 1, exportedAt: new Date().toISOString(), tables: { user: db.prepare(`SELECT * FROM "user"`).all(), setting: [], challenge: [], blog_post: [] }, tableCounts: { user: 0, setting: 0, challenge: 0, blog_post: 0 }, uploads: [], }; db.close(); const staged = await restore.stageArchive(JSON.stringify(archive), 'admin-live'); await expect(restore.commitRestore(staged.stagingId)).rejects.toMatchObject({ code: ERROR_CODES.SYSTEM_RESTORE_ROLLED_BACK, }); const liveDb = new Database(dbPath, { readonly: true }); const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value; const userCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c; liveDb.close(); expect(title).toBe('MODIFIED TITLE'); expect(userCount).toBe(2); expect(fs.readFileSync(path.join(uploadDir, 'keep.txt')).toString()).toBe('KEEP'); 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 */ } }); });