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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user