Files
HIPCTF2/tests/backend/admin-system-restore-commit.spec.ts
T

359 lines
16 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 { 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 });
});
});