Files
HIPCTF2/tests/backend/challenges-board.spec.ts
T
2026-07-23 00:13:57 +00:00

243 lines
9.5 KiB
TypeScript

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{');
});
});