feat: Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.03

This commit is contained in:
OpenVelo Agent
2026-07-22 22:18:07 +00:00
parent edb3feb457
commit d20e37d84b
7 changed files with 472 additions and 103 deletions
@@ -0,0 +1,249 @@
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 * 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 { Repository } from 'typeorm';
import { getRepositoryToken } 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 { 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('Job 903: AdminChallengesService - confirmed import replaces the full challenge set', () => {
let service: AdminChallengesService;
let fileService: ChallengeFilesService;
let catRepo: Repository<CategoryEntity>;
let chRepo: Repository<ChallengeEntity>;
let fileRepo: Repository<ChallengeFileEntity>;
let cryCategoryId: string;
let uploadDir: string;
let finalDir: string;
beforeAll(async () => {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-import-replace-'));
process.env.UPLOAD_DIR = uploadDir;
finalDir = path.join(uploadDir, 'challenges');
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));
fileRepo = moduleRef.get<Repository<ChallengeFileEntity>>(getRepositoryToken(ChallengeFileEntity));
const cry = await catRepo.findOne({ where: { systemKey: 'CRY' } });
if (!cry) throw new Error('expected seeded CRY category');
cryCategoryId = cry.id;
});
afterAll(async () => {
if (uploadDir && fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
beforeEach(async () => {
// Clear out any challenges left over from a previous test so the name
// uniqueness constraint does not fire.
const all = await service.list({});
for (const row of all) await service.remove(row.id);
});
async function seedChallenge(name: string): Promise<{ id: string; storedFilename: string | null }> {
const staged = fileService.stage(Buffer.from(name + '-bytes'), `${name}.txt`, 'text/plain');
const created = await service.create({
name,
descriptionMd: 'd',
categoryId: cryCategoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: `flag{${name}}`,
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
stagedToken: staged.token,
originalFilename: staged.originalFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
},
],
} as any);
const fileRow = await fileRepo.findOne({ where: { challengeId: created.id } });
return { id: created.id, storedFilename: fileRow ? fileRow.storedFilename : null };
}
function buildDoc(challenges: any[]): any {
return {
format: CHALLENGE_FORMAT,
version: CHALLENGE_FORMAT_VERSION,
exportedAt: '2026-07-22T00:00:00Z',
challenges,
};
}
it('confirmed import wipes unrelated pre-existing challenges', async () => {
await seedChallenge('WipeAlpha');
await seedChallenge('WipeBravo');
const result = await service.importConfirmed(
buildDoc([
{
name: 'ImportedCharlie',
descriptionMd: 'fresh',
categorySystemKey: 'CRY',
difficulty: 'HIGH',
initialPoints: 250,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{charlie}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [],
},
]) as any,
);
expect(result.imported).toBe(1);
expect(result.skipped).toEqual([]);
const names = (await service.list({})).map((r) => r.name).sort();
expect(names).toEqual(['ImportedCharlie']);
expect(await chRepo.count()).toBe(1);
});
it('unlinks the disk files owned by wiped challenges and keeps the imported file', async () => {
const { storedFilename: alphaStored } = await seedChallenge('DiskAlpha');
await service.importConfirmed(
buildDoc([
{
name: 'ImportedDelta',
descriptionMd: 'd',
categorySystemKey: 'CRY',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{delta}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
originalFilename: 'delta.txt',
mimeType: 'text/plain',
sizeBytes: Buffer.byteLength('delta-bytes'),
dataBase64: Buffer.from('delta-bytes').toString('base64'),
},
],
},
]) as any,
);
expect(alphaStored).toBeDefined();
expect(fs.existsSync(path.join(finalDir, alphaStored!))).toBe(false);
const deltaRow = (await service.list({})).find((r) => r.name === 'ImportedDelta');
expect(deltaRow).toBeDefined();
const deltaFiles = await fileRepo.find({ where: { challengeId: deltaRow!.id } });
expect(deltaFiles).toHaveLength(1);
expect(fs.existsSync(path.join(finalDir, deltaFiles[0].storedFilename))).toBe(true);
});
it('empty archive wipes everything and leaves no challenges', async () => {
const { storedFilename: alphaStored } = await seedChallenge('EmptyAlpha');
const result = await service.importConfirmed(buildDoc([]) as any);
expect(result.imported).toBe(0);
expect(result.skipped).toEqual([]);
expect(await chRepo.count()).toBe(0);
expect(await fileRepo.count()).toBe(0);
expect(alphaStored).toBeDefined();
expect(fs.existsSync(path.join(finalDir, alphaStored!))).toBe(false);
});
it('unknown-category rows are reported as skipped but still wipe everything else', async () => {
await seedChallenge('SkipAlpha');
await seedChallenge('SkipBravo');
const result = await service.importConfirmed(
buildDoc([
{
name: 'Ghost',
descriptionMd: 'd',
categorySystemKey: 'NO_SUCH_CATEGORY',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{ghost}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [],
},
]) as any,
);
expect(result.imported).toBe(0);
expect(result.skipped).toEqual([
{ name: 'Ghost', reason: 'Unknown category: NO_SUCH_CATEGORY' },
]);
expect(await service.list({})).toEqual([]);
expect(await chRepo.count()).toBe(0);
});
});