feat: Admin Area Challenges: List, Import/Export and Add/Edit Modal
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user