process.env.DATABASE_PATH = ':memory:'; process.env.THEMES_DIR = './themes'; process.env.FRONTEND_DIST = './frontend/dist'; process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-import-test'; process.env.UPLOAD_SIZE_LIMIT = '5mb'; process.env.NODE_ENV = 'test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { Test } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { validateEnv } from '../../backend/src/config/env.schema'; import { AdminChallengesService } from '../../backend/src/modules/admin/challenges.service'; import { ChallengeFilesService } from '../../backend/src/modules/admin/challenge-files.service'; import { CategoryEntity } from '../../backend/src/database/entities/category.entity'; import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity'; import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity'; import { SolveEntity } from '../../backend/src/database/entities/solve.entity'; import { UserEntity } from '../../backend/src/database/entities/user.entity'; import { SettingEntity } from '../../backend/src/database/entities/setting.entity'; import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1700000000000-InitSchema'; import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData'; import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev'; import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys'; import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories'; import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema'; import { CHALLENGE_FORMAT, CHALLENGE_FORMAT_VERSION } from '../../backend/src/modules/admin/dto/challenges.dto'; describe('AdminChallengesService - import/export', () => { let service: AdminChallengesService; let fileService: ChallengeFilesService; let catRepo: Repository; let chRepo: Repository; let cryCategoryId: string; let webCategoryId: string; let uploadDir: string; beforeAll(async () => { uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-import-')); process.env.UPLOAD_DIR = uploadDir; const moduleRef = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), TypeOrmModule.forRoot({ type: 'better-sqlite3', database: ':memory:', entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity], migrations: [ InitSchema1700000000000, SeedSystemData1700000000100, AddCategoryTimestampsAndUniqueAbbrev1700000000200, UpdateSystemCategoryKeys1700000000300, RepairCategorySchemaAndSystemCategories1700000000400, UpgradeChallengeAdminSchema1700000000500, ], migrationsRun: true, synchronize: false, }), TypeOrmModule.forFeature([CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity]), ], providers: [AdminChallengesService, ChallengeFilesService], }).compile(); moduleRef.init(); service = moduleRef.get(AdminChallengesService); fileService = moduleRef.get(ChallengeFilesService); catRepo = moduleRef.get>(getRepositoryToken(CategoryEntity)); chRepo = moduleRef.get>(getRepositoryToken(ChallengeEntity)); const cry = await catRepo.findOne({ where: { systemKey: 'CRY' } }); const web = await catRepo.findOne({ where: { systemKey: 'WEB' } }); if (!cry || !web) throw new Error('expected seeded categories'); cryCategoryId = cry.id; webCategoryId = web.id; }); afterAll(async () => { if (uploadDir && fs.existsSync(uploadDir)) { try { fs.rmSync(uploadDir, { recursive: true, force: true }); } catch { // ignore } } }); it('exports the current challenges with the versioned envelope', async () => { // seed one challenge const staged = fileService.stage(Buffer.from('hello'), 'doc.txt', 'text/plain'); await service.create({ name: 'Exportable', descriptionMd: 'd', categoryId: cryCategoryId, difficulty: 'LOW', initialPoints: 100, minimumPoints: 50, decaySolves: 10, flag: 'flag{X}', protocol: 'NC', port: 1337, ipAddress: '', enabled: true, files: [ { stagedToken: staged.token, originalFilename: staged.originalFilename, mimeType: staged.mimeType, sizeBytes: staged.sizeBytes, }, ], } as any); const doc = await service.exportAll(); expect(doc.format).toBe(CHALLENGE_FORMAT); expect(doc.version).toBe(CHALLENGE_FORMAT_VERSION); expect(doc.exportedAt).toMatch(/T.+Z$/); const ours = doc.challenges.find((c: any) => c.name === 'Exportable'); expect(ours).toBeDefined(); expect(ours.categorySystemKey).toBe('CRY'); expect(ours.flag).toBe('flag{X}'); expect(ours.port).toBe(1337); expect(ours.files).toHaveLength(1); expect(Buffer.from(ours.files[0].dataBase64, 'base64').toString()).toBe('hello'); }); it('returns an empty challenges array when there are no challenges', async () => { // remove everything for the assertion const all = await service.list({}); for (const row of all) await service.remove(row.id); const doc = await service.exportAll(); expect(doc.challenges).toEqual([]); }); it('previewImport flags conflicts and unknown categories without mutating', async () => { // add one challenge to make a conflict await service.create({ name: 'Conflict X', descriptionMd: 'd', categoryId: cryCategoryId, difficulty: 'LOW', initialPoints: 100, minimumPoints: 50, decaySolves: 10, flag: 'f', protocol: 'WEB', port: null, ipAddress: '', enabled: true, } as any); const preview = await service.previewImport({ format: CHALLENGE_FORMAT, version: CHALLENGE_FORMAT_VERSION, exportedAt: '2026-07-22T00:00:00Z', challenges: [ { name: 'CONFLICT X', descriptionMd: 'd', categorySystemKey: 'CRY', difficulty: 'LOW', initialPoints: 100, minimumPoints: 50, decaySolves: 10, flag: 'f', protocol: 'WEB', port: null, ipAddress: '', enabled: true, files: [], }, { name: 'New One', descriptionMd: 'd', categorySystemKey: 'UNKNOWN_CAT', difficulty: 'LOW', initialPoints: 100, minimumPoints: 50, decaySolves: 10, flag: 'f', protocol: 'WEB', port: null, ipAddress: '', enabled: true, files: [], }, ], } as any); expect(preview.total).toBe(2); expect(preview.conflicts).toContain('CONFLICT X'); expect(preview.unknownCategories).toContain('UNKNOWN_CAT'); }); it('confirmed import overwrites by case-insensitive name and creates new entries', async () => { const payload = { format: CHALLENGE_FORMAT, version: CHALLENGE_FORMAT_VERSION, exportedAt: '2026-07-22T00:00:00Z', challenges: [ { name: 'Conflict X', descriptionMd: 'updated body', categorySystemKey: 'CRY', difficulty: 'MEDIUM', initialPoints: 200, minimumPoints: 100, decaySolves: 5, flag: 'flag{new}', protocol: 'NC', port: 9000, ipAddress: '127.0.0.1', enabled: true, files: [ { originalFilename: 'replace.txt', mimeType: 'text/plain', sizeBytes: Buffer.byteLength('replaced'), dataBase64: Buffer.from('replaced').toString('base64'), }, ], }, { name: 'Brand New', descriptionMd: 'fresh', categorySystemKey: 'WEB', difficulty: 'HIGH', initialPoints: 250, minimumPoints: 50, decaySolves: 10, flag: 'flag{new2}', protocol: 'WEB', port: null, ipAddress: '', enabled: true, files: [], }, ], }; const result = await service.importConfirmed(payload as any); expect(result.imported).toBe(2); expect(result.skipped).toEqual([]); const all = await service.list({}); const updated = all.find((r) => r.name.toLowerCase() === 'conflict x'); const fresh = all.find((r) => r.name.toLowerCase() === 'brand new'); expect(updated).toBeDefined(); expect(fresh).toBeDefined(); // (name is lowercased on the second import; the row stored is the new one) expect(fresh!.categoryAbbreviation).toBe('WEB'); }); it('importConfirmed rolls back DB changes when staging a file fails (sizeBytes mismatch)', async () => { const before = await chRepo.count(); const payload = { format: CHALLENGE_FORMAT, version: CHALLENGE_FORMAT_VERSION, exportedAt: '2026-07-22T00:00:00Z', challenges: [ { name: 'Bad Size', descriptionMd: 'd', categorySystemKey: 'CRY', difficulty: 'LOW', initialPoints: 100, minimumPoints: 50, decaySolves: 10, flag: 'f', protocol: 'WEB', port: null, ipAddress: '', enabled: true, files: [ { originalFilename: 'bad.txt', mimeType: 'text/plain', sizeBytes: 999, dataBase64: Buffer.from('short').toString('base64'), }, ], }, ], }; await expect(service.importConfirmed(payload as any)).rejects.toMatchObject({ code: 'VALIDATION_FAILED', }); const after = await chRepo.count(); expect(after).toBe(before); }); });