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 { 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; let categoryRepo: Repository; 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>(getRepositoryToken(ChallengeEntity)); categoryRepo = app.get>(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{'); }); });