feat: Challenges Page and Challenge Solve Modal
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-board-test';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } 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 { v4 as uuid } from 'uuid';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
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';
|
||||
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
||||
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||
|
||||
async function setRunningWindow(svc: SettingsService): Promise<void> {
|
||||
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString());
|
||||
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
|
||||
}
|
||||
|
||||
describe('GET /api/v1/challenges/board (authenticated)', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let adminCsrf: string;
|
||||
let settings: SettingsService;
|
||||
let challengeRepo: Repository<ChallengeEntity>;
|
||||
let categoryRepo: Repository<CategoryEntity>;
|
||||
let cry: CategoryEntity;
|
||||
let web: CategoryEntity;
|
||||
|
||||
beforeAll(async () => {
|
||||
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 ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
|
||||
settings = app.get(SettingsService);
|
||||
challengeRepo = app.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||
categoryRepo = app.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||
|
||||
cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||
web = (await categoryRepo.findOne({ where: { systemKey: 'WEB' } }))!;
|
||||
if (!cry || !web) throw new Error('expected CRY and WEB seeded categories');
|
||||
|
||||
// Seed: 2 challenges under CRY (LOW, HIGH) and 1 under WEB (MEDIUM); one disabled.
|
||||
await challengeRepo.save([
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'alphacipher',
|
||||
descriptionMd: '# Alpha\n',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 200,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{alpha}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}),
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'Zeta-key',
|
||||
descriptionMd: '# Zeta\n',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'HIGH',
|
||||
initialPoints: 500,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 10,
|
||||
flag: 'flag{zeta}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}),
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'webmid',
|
||||
descriptionMd: '# web\n',
|
||||
categoryId: web.id,
|
||||
difficulty: 'MEDIUM',
|
||||
initialPoints: 300,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{web}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}),
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'hidden',
|
||||
descriptionMd: 'hidden',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{hidden}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
await setRunningWindow(settings);
|
||||
|
||||
// Bootstrap admin and login.
|
||||
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='));
|
||||
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const login = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${adminCsrf}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = login.body.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/challenges/board').expect(401);
|
||||
});
|
||||
|
||||
it('returns columns sorted by abbreviation with cards sorted by difficulty then name', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body.columns)).toBe(true);
|
||||
const abbrs = res.body.columns.map((c: any) => c.abbreviation);
|
||||
expect(abbrs).toEqual([...abbrs].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())));
|
||||
|
||||
const cryCol = res.body.columns.find((c: any) => c.abbreviation === 'CRY');
|
||||
expect(cryCol).toBeDefined();
|
||||
const difficulties = cryCol.cards.map((card: any) => card.difficulty);
|
||||
expect(difficulties).toEqual(['LOW', 'HIGH']);
|
||||
const enabledCardIds = res.body.columns.flatMap((c: any) => c.cards.map((card: any) => card.id));
|
||||
// hidden should be excluded
|
||||
expect(enabledCardIds).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('strips the flag from every response (no plaintext leak)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const json = JSON.stringify(res.body);
|
||||
expect(json).not.toContain('flag{alpha}');
|
||||
expect(json).not.toContain('flag{zeta}');
|
||||
expect(json).not.toContain('flag{web}');
|
||||
expect(json).not.toContain('flag{hidden}');
|
||||
expect(json).not.toContain('"flag"');
|
||||
});
|
||||
|
||||
it('omits per-card solvers by default (keeps payload small)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const allCards: any[] = res.body.columns.flatMap((c: any) => c.cards);
|
||||
for (const card of allCards) {
|
||||
expect(card.solvers).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /api/v1/challenges/:id returns detail with solvers + no flag leak', async () => {
|
||||
const list = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const firstCardId: string = list.body.columns[0].cards[0].id;
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/api/v1/challenges/${firstCardId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.challenge.id).toBe(firstCardId);
|
||||
expect(Array.isArray(res.body.solvers)).toBe(true);
|
||||
const json = JSON.stringify(res.body);
|
||||
expect(json).not.toContain('"flag"');
|
||||
expect(json).not.toContain('flag{');
|
||||
});
|
||||
|
||||
it('?include=solvers attaches per-card solvers sorted by solve time', async () => {
|
||||
const list = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const cardId: string = list.body.columns[0].cards[0].id;
|
||||
// Solve it via the submit endpoint to populate solvers.
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/challenges/${cardId}/solves`)
|
||||
.set('Cookie', `csrf=${adminCsrf}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ flag: list.body.columns[0].cards[0].name === 'alphacipher' ? 'flag{alpha}' : 'flag{zeta}' })
|
||||
.expect(200);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board?include=solvers')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const cardWithSolvers: any = res.body.columns
|
||||
.flatMap((c: any) => c.cards)
|
||||
.find((card: any) => card.id === cardId);
|
||||
expect(cardWithSolvers.solvers).toBeDefined();
|
||||
expect(Array.isArray(cardWithSolvers.solvers)).toBe(true);
|
||||
expect(cardWithSolvers.solvers[0]).toHaveProperty('position');
|
||||
expect(cardWithSolvers.solvers[0]).toHaveProperty('playerName');
|
||||
expect(cardWithSolvers.solvers[0]).toHaveProperty('awardedPoints');
|
||||
const json = JSON.stringify(res.body);
|
||||
expect(json).not.toContain('"flag"');
|
||||
expect(json).not.toContain('flag{');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user