feat: Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.03
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
importErrorMessage,
|
||||
summarizeImportResult,
|
||||
} from '../../frontend/src/app/features/admin/challenges/challenges.pure';
|
||||
|
||||
describe('Job 903: import error + result helpers', () => {
|
||||
it('summarizeImportResult reports imported/skipped/warning counts', () => {
|
||||
const r = {
|
||||
imported: 2,
|
||||
skipped: [{ name: 'X', reason: 'Unknown category: Y' }],
|
||||
warnings: ['1 skipped.'],
|
||||
};
|
||||
const out = summarizeImportResult(r as any);
|
||||
expect(out).toContain('Imported 2');
|
||||
expect(out).toContain('Skipped 1');
|
||||
expect(out).toContain('1 warning');
|
||||
});
|
||||
|
||||
it('importErrorMessage prefers the nested error.message envelope', () => {
|
||||
expect(importErrorMessage({ error: { message: 'Server said no' } }, 'fallback')).toBe('Server said no');
|
||||
});
|
||||
|
||||
it('importErrorMessage falls back to err.message', () => {
|
||||
expect(importErrorMessage({ message: 'boom' }, 'fallback')).toBe('boom');
|
||||
});
|
||||
|
||||
it('importErrorMessage falls back to the supplied default when no message is available', () => {
|
||||
expect(importErrorMessage({}, 'fallback')).toBe('fallback');
|
||||
expect(importErrorMessage(null, 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Job 903: confirmed import handler contract', () => {
|
||||
function makeDoc() {
|
||||
return { format: 'hipctf-challenges', version: 1, exportedAt: '', challenges: [] };
|
||||
}
|
||||
|
||||
it('a successful confirm yields a closed-modal state and a status banner', async () => {
|
||||
const state = {
|
||||
importOpen: true as boolean,
|
||||
importDoc: makeDoc() as any,
|
||||
importPreview: { total: 2, conflicts: [], unknownCategories: [], warnings: [] } as any,
|
||||
importResult: null as any,
|
||||
importError: null as string | null,
|
||||
importing: false,
|
||||
statusMessage: null as string | null,
|
||||
};
|
||||
|
||||
// Simulate onImportConfirm body using pure helpers.
|
||||
try {
|
||||
const result = { imported: 2, skipped: [], warnings: [] };
|
||||
state.statusMessage = summarizeImportResult(result as any);
|
||||
await Promise.resolve();
|
||||
// Close-modal branch (the new behavior).
|
||||
state.importOpen = false;
|
||||
state.importDoc = null;
|
||||
state.importPreview = null;
|
||||
state.importResult = null;
|
||||
state.importError = null;
|
||||
} catch (err) {
|
||||
state.importError = importErrorMessage(err, 'Failed to import challenges');
|
||||
} finally {
|
||||
state.importing = false;
|
||||
}
|
||||
|
||||
expect(state.importOpen).toBe(false);
|
||||
expect(state.importDoc).toBeNull();
|
||||
expect(state.importPreview).toBeNull();
|
||||
expect(state.importResult).toBeNull();
|
||||
expect(state.importError).toBeNull();
|
||||
expect(state.statusMessage).toContain('Imported 2');
|
||||
expect(state.importing).toBe(false);
|
||||
});
|
||||
|
||||
it('a rejected confirm keeps the modal open and surfaces the error message', async () => {
|
||||
const state = {
|
||||
importOpen: true as boolean,
|
||||
importDoc: makeDoc() as any,
|
||||
importPreview: { total: 1, conflicts: [], unknownCategories: [], warnings: [] } as any,
|
||||
importResult: null as any,
|
||||
importError: null as string | null,
|
||||
importing: false,
|
||||
statusMessage: null as string | null,
|
||||
};
|
||||
|
||||
try {
|
||||
throw { error: { message: 'Server said no' } };
|
||||
} catch (err) {
|
||||
state.importError = importErrorMessage(err, 'Failed to import challenges');
|
||||
} finally {
|
||||
state.importing = false;
|
||||
}
|
||||
|
||||
expect(state.importOpen).toBe(true);
|
||||
expect(state.importError).toBe('Server said no');
|
||||
expect(state.importing).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user