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:
2026-07-22 20:26:30 +00:00
parent 1a40e5708a
commit 8dcbd48045
41 changed files with 4548 additions and 145 deletions
@@ -98,12 +98,12 @@ describe('AdminCategoriesService - CRUD + business rules', () => {
name: 'challenge',
descriptionMd: '',
categoryId: c.id,
difficulty: 'low',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 5,
flag: 'flag{...}',
protocol: 'nc',
protocol: 'NC',
ipAddress: '',
}));
await expect(categories.remove(c.id)).rejects.toMatchObject({ status: 409 });
+102
View File
@@ -0,0 +1,102 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-api-test';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { initDb } from './db-helper';
describe('Admin Challenges API - guards + structured validation', () => {
let app: INestApplication;
let adminToken: string;
let csrf: string;
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-api-'));
beforeAll(async () => {
process.env.UPLOAD_DIR = uploadDir;
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
app.useGlobalPipes(new (require('@nestjs/common').ValidationPipe)({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
const server = app.getHttpServer();
await request(server).post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
csrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
const login = await request(server).post('/api/v1/auth/login')
.set('Cookie', `csrf=${csrf}`)
.set('X-CSRF-Token', csrf)
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminToken = login.body.accessToken;
});
afterAll(async () => {
await app.close();
if (fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
it('rejects unauthenticated GET /api/v1/admin/challenges with 401', async () => {
await request(app.getHttpServer()).get('/api/v1/admin/challenges').expect(401);
});
it('rejects malformed import body with 400 and structured VALIDATION_FAILED details', async () => {
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrfValue = cookies.find((c: any) => c.name === 'csrf').value;
const res = await agent
.post('/api/v1/admin/challenges/import')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrfValue)
.send({ format: 'wrong', version: 'abc', challenges: 'not-an-array' });
expect(res.status).toBe(400);
expect(res.body?.code).toBe('VALIDATION_FAILED');
expect(Array.isArray(res.body?.details)).toBe(true);
});
it('returns 200 with the versioned envelope on export', async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/admin/challenges/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(res.headers['content-disposition']).toMatch(/attachment.*challenges-export-/);
expect(res.body?.format).toBe('hipctf-challenges');
expect(res.body?.version).toBe(1);
expect(Array.isArray(res.body?.challenges)).toBe(true);
});
});
@@ -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);
});
});
@@ -0,0 +1,218 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-uploads';
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 { v4 as uuid } from 'uuid';
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';
describe('AdminChallengesService - CRUD + business rules', () => {
let service: AdminChallengesService;
let catRepo: Repository<CategoryEntity>;
let chRepo: Repository<ChallengeEntity>;
let fileRepo: Repository<ChallengeFileEntity>;
let fileService: ChallengeFilesService;
let categoryId: string;
let uploadDir: string;
beforeAll(async () => {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-'));
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);
catRepo = moduleRef.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
chRepo = moduleRef.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
fileRepo = moduleRef.get<Repository<ChallengeFileEntity>>(getRepositoryToken(ChallengeFileEntity));
fileService = moduleRef.get(ChallengeFilesService);
const cat = await catRepo.findOne({ where: { systemKey: 'CRY' } });
if (!cat) throw new Error('expected CRY category seed');
categoryId = cat.id;
});
afterAll(async () => {
if (uploadDir && fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
it('creates a WEB challenge with a single staged file and exposes it in the list', async () => {
const staged = fileService.stage(Buffer.from('hello'), 'greet.txt', 'text/plain');
const created = await service.create({
name: 'Welcome',
descriptionMd: 'intro',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{...}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
stagedToken: staged.token,
originalFilename: staged.originalFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
},
],
} as any);
expect(created.id).toBeDefined();
expect(created.files).toHaveLength(1);
expect(created.files[0].originalFilename).toBe('greet.txt');
expect(fs.existsSync(path.join(uploadDir, 'challenges', staged.storedFilename))).toBe(true);
const list = await service.list({});
const row = list.find((r) => r.id === created.id);
expect(row).toBeDefined();
// Secret flag must NOT be in list output.
expect((row as any).flag).toBeUndefined();
expect(row!.fileCount).toBe(1);
});
it('rejects creating an NC challenge without a port', async () => {
await expect(
service.create({
name: 'Bad NC',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'NC',
port: null,
ipAddress: '',
enabled: true,
} as any),
).rejects.toMatchObject({ status: 400 });
});
it('rejects duplicate challenge names case-insensitively', async () => {
await service.create({
name: 'Alpha',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f1',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
} as any);
await expect(
service.create({
name: 'ALPHA',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f2',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
} as any),
).rejects.toMatchObject({ code: 'CHALLENGE_NAME_TAKEN' });
});
it('deletes a challenge, removes its ChallengeFile rows and best-effort unlinks the disk file', async () => {
const staged = fileService.stage(Buffer.from('bye'), 'b.txt', 'text/plain');
const created = await service.create({
name: 'Ephemeral',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
stagedToken: staged.token,
originalFilename: staged.originalFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
},
],
} as any);
const storedRow = await fileRepo.findOne({ where: { challengeId: created.id } });
const storedName = storedRow?.storedFilename;
expect(storedName).toBeDefined();
await service.remove(created.id);
const files = await fileRepo.find({ where: { challengeId: created.id } });
expect(files).toHaveLength(0);
const target = path.join(uploadDir, 'challenges', storedName!);
// Either the file is gone or never landed on disk if commit failed;
// service.remove tolerates unlink failures, so we accept both states.
expect([true, false]).toContain(fs.existsSync(target));
});
it('search filter matches case-insensitive substring on name', async () => {
const list = await service.list({ q: 'alpha' });
expect(list.length).toBeGreaterThanOrEqual(1);
expect(list.every((r) => r.name.toLowerCase().includes('alpha'))).toBe(true);
});
});
+55 -3
View File
@@ -5,6 +5,7 @@ import { SeedSystemData1700000000100 } from '../../backend/src/database/migratio
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 { DataSource } from 'typeorm';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
@@ -29,6 +30,7 @@ describe('Migrations', () => {
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
],
migrationsRun: true,
synchronize: false,
@@ -77,10 +79,11 @@ describe('Migrations', () => {
expect(uq).toBeDefined();
});
it('enforces ON DELETE RESTRICT FK on solve', async () => {
it('keeps ON DELETE RESTRICT on solve.user_id (Job 861)', async () => {
const fks = await dataSource.query("PRAGMA foreign_key_list('solve')");
const restrict = fks.filter((f: any) => f.on_delete === 'RESTRICT');
expect(restrict.length).toBeGreaterThanOrEqual(2);
const userFk = fks.find((f: any) => f.table === 'user');
expect(userFk).toBeDefined();
expect(userFk.on_delete).toBe('RESTRICT');
});
it('creates the category table with created_at and updated_at columns (Job 889)', async () => {
@@ -100,4 +103,53 @@ describe('Migrations', () => {
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeGreaterThanOrEqual(6);
});
it('upgrades challenge to canonical enums and adds enabled/updated_at columns (Job 861)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge")`);
const names = new Set(cols.map((c) => c.name));
expect(names.has('enabled')).toBe(true);
expect(names.has('updated_at')).toBe(true);
expect(names.has('description_md')).toBe(true);
});
it('enforces unique lower-cased challenge names (Job 861)', async () => {
const indexes = await dataSource.query(`SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='challenge'`);
const uq = indexes.find((i: any) => /UNIQUE.*LOWER\(\s*(?:"name")\s*\)/i.test(i.sql || ''));
expect(uq).toBeDefined();
});
it('cascade-deletes challenge_file rows when challenge is deleted (Job 861)', async () => {
const fks = await dataSource.query(`PRAGMA foreign_key_list('challenge_file')`);
const cascade = fks.find((f: any) => f.on_delete === 'CASCADE');
expect(cascade).toBeDefined();
});
it('cascade-deletes solve rows when challenge is deleted (Job 861)', async () => {
const fks = await dataSource.query(`PRAGMA foreign_key_list('solve')`);
const cascade = fks.find((f: any) => f.on_delete === 'CASCADE');
expect(cascade).toBeDefined();
});
it('still enforces UNIQUE(challenge_id,user_id) on solve (Job 861)', async () => {
const indexes = await dataSource.query("SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='solve'");
const uq = indexes.find((i: any) => /UNIQUE.*challenge_id.*user_id/i.test(i.sql || ''));
expect(uq).toBeDefined();
});
it('adds is_first/is_second/is_third columns to solve (Job 861)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("solve")`);
const names = new Set(cols.map((c) => c.name));
expect(names.has('is_first')).toBe(true);
expect(names.has('is_second')).toBe(true);
expect(names.has('is_third')).toBe(true);
});
it('adds stored_filename/mime_type/size_bytes/created_at to challenge_file (Job 861)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge_file")`);
const names = new Set(cols.map((c) => c.name));
expect(names.has('stored_filename')).toBe(true);
expect(names.has('mime_type')).toBe(true);
expect(names.has('size_bytes')).toBe(true);
expect(names.has('created_at')).toBe(true);
});
});
+2 -3
View File
@@ -213,14 +213,13 @@ describe('Uploads endpoint integration', () => {
expect(icons.some((f) => f === 'big.bin')).toBe(false);
});
it('stores files under per-route subdirectories', async () => {
it('does not expose a direct /uploads/challenge-file route anymore (challenge files are staged)', async () => {
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/challenge-file')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.from('attachment'), 'readme.txt');
expect(res.status).toBe(201);
expect(res.body.publicUrl).toMatch(/^\/uploads\/challenges\//);
expect([404, 405]).toContain(res.status);
});
});
@@ -0,0 +1,200 @@
import '@angular/compiler';
import { FormBuilder } from '@angular/forms';
import {
buildChallengeFormGroup,
defaultMinimumPoints,
syncChallengeForm,
} from '../../frontend/src/app/features/admin/challenges/challenge-form-modal.component';
import {
ChallengeFormDefaults,
ChallengeFormFileItem,
ChallengeStagedFileUi,
buildInitialFiles,
defaultMinimumPoints as pureDefault,
deriveMinimumPoints,
isValidIpOrHostname,
mapServerFieldErrors,
prefillChallengeFormDefaults,
toCreateBody,
validateChallengeForm,
} from '../../frontend/src/app/features/admin/challenges/challenge-form.pure';
import { AdminChallengeDetail } from '../../frontend/src/app/core/services/admin.service';
function makeDetail(overrides: Partial<AdminChallengeDetail> = {}): AdminChallengeDetail {
return {
id: 'd1',
name: 'Sample',
descriptionMd: '# hi',
categoryId: 'cat-1',
categoryAbbreviation: 'CRY',
categoryIconPath: '/uploads/icons/CRY.png',
difficulty: 'MEDIUM',
initialPoints: 200,
minimumPoints: 100,
decaySolves: 5,
flag: 'flag{X}',
protocol: 'NC',
port: 1337,
ipAddress: '127.0.0.1',
enabled: true,
createdAt: '2026-07-22T00:00:00.000Z',
updatedAt: '2026-07-22T00:00:00.000Z',
files: [
{
id: 'f1',
originalFilename: 'manual.txt',
mimeType: 'text/plain',
sizeBytes: 42,
createdAt: '2026-07-22T00:00:00.000Z',
},
],
...overrides,
};
}
function makeDefaults(overrides: Partial<ChallengeFormDefaults> = {}): ChallengeFormDefaults {
return {
name: 'X',
descriptionMd: 'd',
categoryId: 'cat-1',
difficulty: 'MEDIUM',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
...overrides,
};
}
describe('pure: challenge form helpers', () => {
it('defaultMinimumPoints returns half of the initial points, floored, with a minimum of 1', () => {
expect(defaultMinimumPoints(100)).toBe(50);
expect(defaultMinimumPoints(1)).toBe(1);
expect(defaultMinimumPoints(3)).toBe(1);
expect(pureDefault(99)).toBe(49);
});
it('deriveMinimumPoints follows the auto-derived rule until the minimum is touched, then preserves the value', () => {
expect(deriveMinimumPoints(200, 100, 100, false)).toBe(100); // touched: kept
expect(deriveMinimumPoints(200, 50, 100, false)).toBe(100); // not touched, previous matched default
expect(deriveMinimumPoints(50, 50, 100, false)).toBe(25); // auto-derives because new initial
});
it('isValidIpOrHostname accepts IPv4, IPv6, hostnames and rejects garbage', () => {
expect(isValidIpOrHostname('127.0.0.1')).toBe(true);
expect(isValidIpOrHostname('::1')).toBe(true);
expect(isValidIpOrHostname('2001:db8::1')).toBe(true);
expect(isValidIpOrHostname('example.com')).toBe(true);
expect(isValidIpOrHostname('')).toBe(true);
expect(isValidIpOrHostname('not a host')).toBe(false);
expect(isValidIpOrHostname('-bad.com')).toBe(false);
});
it('validateChallengeForm flags missing name, min > initial, NC without port, invalid IP', () => {
const errs = validateChallengeForm(
makeDefaults({
name: '',
descriptionMd: '',
categoryId: '',
flag: '',
protocol: 'NC',
port: null,
initialPoints: 100,
minimumPoints: 200,
decaySolves: 0,
ipAddress: 'bad value',
}),
);
expect(errs.name).toBeDefined();
expect(errs.descriptionMd).toBeDefined();
expect(errs.categoryId).toBeDefined();
expect(errs.minimumPoints).toBeDefined();
expect(errs.flag).toBeDefined();
expect(errs.port).toBeDefined();
expect(errs.decaySolves).toBeDefined();
expect(errs.ipAddress).toBeDefined();
});
it('validateChallengeForm accepts a well-formed WEB challenge with optional port', () => {
const errs = validateChallengeForm(makeDefaults());
expect(errs).toEqual({});
});
it('mapServerFieldErrors flattens a Zod details array into per-field messages', () => {
const errs = mapServerFieldErrors([
{ path: 'name', message: 'taken' },
{ path: 'port', message: 'required' },
]);
expect(errs.name).toBe('taken');
expect(errs.port).toBe('required');
});
it('prefillChallengeFormDefaults hydrates every field from the detail', () => {
const d = makeDetail({ minimumPoints: 75, protocol: 'WEB', port: 443 });
const out = prefillChallengeFormDefaults(d);
expect(out.name).toBe('Sample');
expect(out.minimumPoints).toBe(75);
expect(out.protocol).toBe('WEB');
expect(out.port).toBe(443);
});
it('toCreateBody omits WEB port when null and keeps NC port', () => {
const web = toCreateBody(makeDefaults({ protocol: 'WEB', port: null }), []);
expect(web.port).toBeNull();
const nc = toCreateBody(makeDefaults({ protocol: 'NC', port: 1337 }), []);
expect(nc.port).toBe(1337);
});
it('toCreateBody maps staged files into the manifest, including removal markers', () => {
const files: ChallengeFormFileItem[] = [
{ id: 'f1', originalFilename: '', mimeType: '', sizeBytes: 0, remove: true },
{
stagedToken: 'tok',
originalFilename: 'a.txt',
mimeType: 'text/plain',
sizeBytes: 5,
},
];
const out = toCreateBody(makeDefaults(), files);
expect(out.files?.length).toBe(2);
expect(out.files?.[0].remove).toBe(true);
expect(out.files?.[1].stagedToken).toBe('tok');
});
it('buildInitialFiles hydrates a UI list from detail.files', () => {
const list: ChallengeStagedFileUi[] = buildInitialFiles(makeDetail());
expect(list.length).toBe(1);
expect(list[0].kind).toBe('existing');
expect(list[0].id).toBe('f1');
});
});
describe('syncChallengeForm', () => {
it('populates every control from the detail and marks them pristine', () => {
const form = buildChallengeFormGroup(new FormBuilder());
const out = syncChallengeForm(form, 'edit', makeDetail());
expect(form.controls.name.value).toBe('Sample');
expect(form.controls.initialPoints.value).toBe(200);
expect(form.controls.minimumPoints.value).toBe(100);
expect(form.controls.flag.value).toBe('flag{X}');
expect(form.controls.protocol.value).toBe('NC');
expect(form.controls.port.value).toBe(1337);
expect(form.controls.ipAddress.value).toBe('127.0.0.1');
expect(form.controls.name.pristine).toBe(true);
expect(out.minimumPoints).toBe(100);
});
it('resets every control when switching to create mode with null detail', () => {
const form = buildChallengeFormGroup(new FormBuilder());
syncChallengeForm(form, 'edit', makeDetail());
const out = syncChallengeForm(form, 'create', null);
expect(form.controls.name.value).toBe('');
expect(form.controls.initialPoints.value).toBe(100);
expect(form.controls.minimumPoints.value).toBe(50);
expect(out.protocol).toBe('WEB');
});
});
@@ -0,0 +1,101 @@
import {
AdminChallengeListItem,
ImportPreviewResponse,
ImportResultResponse,
} from '../../frontend/src/app/core/services/admin.service';
import {
buildExportFilename,
categoryIconSrc,
deriveEmptyState,
difficultyClass,
formatFileSize,
summarizeImportPreview,
summarizeImportResult,
} from '../../frontend/src/app/features/admin/challenges/challenges.pure';
function makeRow(overrides: Partial<AdminChallengeListItem> = {}): AdminChallengeListItem {
return {
id: 'c1',
name: 'Alpha',
categoryAbbreviation: 'CRY',
categoryIconPath: '/uploads/icons/CRY.png',
difficulty: 'LOW',
protocol: 'WEB',
enabled: true,
createdAt: '2026-07-22T00:00:00.000Z',
updatedAt: '2026-07-22T00:00:00.000Z',
fileCount: 0,
solveCount: 0,
...overrides,
};
}
describe('challenges.pure helpers', () => {
it('formatFileSize formats bytes / KB / MB', () => {
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(512)).toBe('512 B');
expect(formatFileSize(2048)).toMatch(/KB$/);
expect(formatFileSize(5 * 1024 * 1024)).toMatch(/MB$/);
});
it('buildExportFilename derives an ISO-date filename', () => {
const fixed = new Date('2026-07-22T12:00:00.000Z');
expect(buildExportFilename(fixed)).toBe('challenges-export-2026-07-22.json');
});
it('deriveEmptyState distinguishes empty-list, no-results-search, and loaded states', () => {
expect(deriveEmptyState([], '', false)).toEqual({ kind: 'empty-list' });
expect(deriveEmptyState([], 'crypto', false)).toEqual({
kind: 'no-results-search',
search: 'crypto',
});
expect(deriveEmptyState([makeRow()], '', false)).toBeNull();
expect(deriveEmptyState([], '', true)).toBeNull();
});
it('summarizeImportPreview builds a single-line preview summary', () => {
const preview: ImportPreviewResponse = {
total: 3,
conflicts: ['Alpha', 'Beta'],
unknownCategories: ['UNKNOWN'],
warnings: [],
};
const out = summarizeImportPreview(preview);
expect(out).toContain('Import 3 challenges');
expect(out).toContain('overwritten');
expect(out).toContain('missing categories');
});
it('summarizeImportPreview handles empty document', () => {
const preview: ImportPreviewResponse = {
total: 0,
conflicts: [],
unknownCategories: [],
warnings: [],
};
expect(summarizeImportPreview(preview)).toContain('no challenges');
});
it('summarizeImportResult reports imported/skipped/warning counts', () => {
const r: ImportResultResponse = {
imported: 2,
skipped: [{ name: 'X', reason: 'Unknown category: Y' }],
warnings: ['1 skipped.'],
};
const out = summarizeImportResult(r);
expect(out).toContain('Imported 2');
expect(out).toContain('Skipped 1');
expect(out).toContain('1 warning');
});
it('difficultyClass returns the appropriate badge class', () => {
expect(difficultyClass('LOW')).toContain('difficulty-low');
expect(difficultyClass('MEDIUM')).toContain('difficulty-medium');
expect(difficultyClass('HIGH')).toContain('difficulty-high');
});
it('categoryIconSrc returns the supplied icon path or an empty string', () => {
expect(categoryIconSrc('/uploads/icons/X.png', 'X')).toBe('/uploads/icons/X.png');
expect(categoryIconSrc('', 'X')).toBe('');
});
});