AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)
This commit was merged in pull request #40.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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<CategoryEntity>;
|
||||
let chRepo: Repository<ChallengeEntity>;
|
||||
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<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||
chRepo = moduleRef.get<Repository<ChallengeEntity>>(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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user