process.env.DATABASE_PATH = ':memory:'; process.env.THEMES_DIR = './themes'; process.env.FRONTEND_DIST = './frontend/dist'; process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-submit-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 * as argon2 from 'argon2'; 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 { SolveEntity } from '../../backend/src/database/entities/solve.entity'; import { UserEntity } from '../../backend/src/database/entities/user.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()); } async function setStoppedWindow(svc: SettingsService): Promise { await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 7 * 24 * 3600_000).toISOString()); await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() - 24 * 3600_000).toISOString()); } describe('POST /api/v1/challenges/:id/solves', () => { let app: INestApplication; let adminToken: string; let adminCsrf: string; let playerAgent: any; let playerToken: string; let playerCsrf: string; let player2Agent: any; let player2Token: string; let player2Csrf: string; let settings: SettingsService; let challengeRepo: Repository; let solveRepo: Repository; let userRepo: Repository; let categoryRepo: Repository; let challenge: ChallengeEntity; let challenge2: ChallengeEntity; let playerId: string; let player2Id: string; 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)); solveRepo = app.get>(getRepositoryToken(SolveEntity)); userRepo = app.get>(getRepositoryToken(UserEntity)); categoryRepo = app.get>(getRepositoryToken(CategoryEntity)); const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; if (!cry) throw new Error('expected CRY seeded category'); challenge = await challengeRepo.save( challengeRepo.create({ id: uuid(), name: 'alphacipher', descriptionMd: '# Alpha\n', categoryId: cry.id, difficulty: 'LOW', initialPoints: 500, minimumPoints: 100, decaySolves: 2, flag: 'flag{alpha}', protocol: 'WEB', port: null, ipAddress: '', enabled: true, }), ); challenge2 = await challengeRepo.save( challengeRepo.create({ id: uuid(), name: 'disabled-chal', descriptionMd: '', categoryId: cry.id, difficulty: 'LOW', initialPoints: 100, minimumPoints: 50, decaySolves: 5, flag: 'flag{x}', protocol: 'WEB', port: null, ipAddress: '', enabled: false, }), ); await setRunningWindow(settings); 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 adminLogin = 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 = adminLogin.body.accessToken; // Create two player users directly. const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id }); const player = await userRepo.save(userRepo.create({ id: uuid(), username: 'player1', passwordHash, role: 'player', status: 'enabled', })); playerId = player.id; const player2 = await userRepo.save(userRepo.create({ id: uuid(), username: 'player2', passwordHash, role: 'player', status: 'enabled', })); player2Id = player2.id; // Login player1. { playerAgent = request.agent(server); await playerAgent.get('/api/v1/auth/csrf'); const cookies: any = playerAgent.jar.getCookies(CookieAccessInfo.All); playerCsrf = cookies.find((c: any) => c.name === 'csrf').value; const login = await playerAgent.post('/api/v1/auth/login') .set('X-CSRF-Token', playerCsrf) .send({ username: 'player1', password: 'PlayerPass!1Ab' }) .expect(201); playerToken = login.body.accessToken; } // Login player2. { player2Agent = request.agent(server); await player2Agent.get('/api/v1/auth/csrf'); const cookies: any = player2Agent.jar.getCookies(CookieAccessInfo.All); player2Csrf = cookies.find((c: any) => c.name === 'csrf').value; const login = await player2Agent.post('/api/v1/auth/login') .set('X-CSRF-Token', player2Csrf) .send({ username: 'player2', password: 'PlayerPass!1Ab' }) .expect(201); player2Token = login.body.accessToken; } }); afterAll(async () => { await app.close(); }); function submit(agent: any, token: string, csrf: string, challengeId: string, flag: string) { return agent .post(`/api/v1/challenges/${challengeId}/solves`) .set('Authorization', `Bearer ${token}`) .set('X-CSRF-Token', csrf) .send({ flag }); } it('rejects unauthenticated requests (CSRF check fires first, returns 403)', async () => { await request(app.getHttpServer()) .post(`/api/v1/challenges/${challenge.id}/solves`) .send({ flag: 'flag{alpha}' }) .expect(403); }); it('rejects unknown challenge with 404 and never writes a row', async () => { const res = await submit(playerAgent, playerToken, playerCsrf, uuid(), 'flag{alpha}'); expect(res.status).toBe(404); expect(res.body?.code).toBe('NOT_FOUND'); const count = await solveRepo.count(); expect(count).toBe(0); }); it('rejects disabled challenge with 404', async () => { const res = await submit(playerAgent, playerToken, playerCsrf, challenge2.id, 'flag{x}'); expect(res.status).toBe(404); }); it('rejects wrong flag with 400 FLAG_INCORRECT and does not write a row', async () => { const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{wrong}'); expect(res.status).toBe(400); expect(res.body?.code).toBe('FLAG_INCORRECT'); const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); expect(count).toBe(0); }); it('rejects submission when event is not running', async () => { await setStoppedWindow(settings); const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); expect(res.status).toBe(409); expect(res.body?.code).toBe('EVENT_NOT_RUNNING'); const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); expect(count).toBe(0); await setRunningWindow(settings); }); it('awards first-blood bonus (base + 15) to the first solver and writes a single row', async () => { const before = await solveRepo.count(); const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); expect(res.status).toBe(200); expect(res.body.status).toBe('solved'); expect(res.body.awarded.position).toBe(1); expect(res.body.awarded.rankBonus).toBe(15); expect(res.body.awarded.basePoints).toBe(500); expect(res.body.awarded.awardedPoints).toBe(515); expect(res.body.challenge.solveCount).toBe(1); const count = await solveRepo.count(); expect(count).toBe(before + 1); }); it('is idempotent on a second correct submission from the same player (no new row, same awarded points)', async () => { const before = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); expect(res.status).toBe(200); expect(res.body.status).toBe('already_solved'); expect(res.body.awarded.awardedPoints).toBe(515); const after = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); expect(after).toBe(before); }); it('does not leak the flag in any response', async () => { const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); const json = JSON.stringify(res.body); expect(json).not.toContain('flag{alpha}'); expect(json).not.toContain('"flag"'); }); it('awards second-blood bonus (base + 10) to the second solver', async () => { const res = await submit(player2Agent, player2Token, player2Csrf, challenge.id, 'flag{alpha}'); expect(res.status).toBe(200); expect(res.body.status).toBe('solved'); expect(res.body.awarded.position).toBe(2); expect(res.body.awarded.rankBonus).toBe(10); // solveCountBefore = 1, base = round(500 - (400) * (1/2)) = 300, awarded = 310 expect(res.body.awarded.basePoints).toBe(300); expect(res.body.awarded.awardedPoints).toBe(310); }); it('two concurrent correct submissions from the same player produce exactly one row', async () => { // Create a fresh challenge + fresh player for this concurrency test. const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; const c = await challengeRepo.save(challengeRepo.create({ id: uuid(), name: 'concurrent', descriptionMd: '', categoryId: cry.id, difficulty: 'LOW', initialPoints: 200, minimumPoints: 100, decaySolves: 5, flag: 'flag{concurrent}', protocol: 'WEB', port: null, ipAddress: '', enabled: true, })); const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id }); const u = await userRepo.save(userRepo.create({ id: uuid(), username: 'concurrent_player', passwordHash, role: 'player', status: 'enabled', })); const agent = request.agent(app.getHttpServer()); await agent.get('/api/v1/auth/csrf'); const cookies: any = agent.jar.getCookies(CookieAccessInfo.All); const csrfValue = cookies.find((x: any) => x.name === 'csrf').value; const login = await agent.post('/api/v1/auth/login') .set('X-CSRF-Token', csrfValue) .send({ username: 'concurrent_player', password: 'PlayerPass!1Ab' }) .expect(201); const token = login.body.accessToken; const [a, b] = await Promise.all([ agent .post(`/api/v1/challenges/${c.id}/solves`) .set('Authorization', `Bearer ${token}`) .set('X-CSRF-Token', csrfValue) .send({ flag: 'flag{concurrent}' }), agent .post(`/api/v1/challenges/${c.id}/solves`) .set('Authorization', `Bearer ${token}`) .set('X-CSRF-Token', csrfValue) .send({ flag: 'flag{concurrent}' }), ]); expect([200, 200]).toContain(a.status); expect([200, 200]).toContain(b.status); const solved = (a.status === 200 && a.body.status === 'solved') || (b.status === 200 && b.body.status === 'solved'); const already = (a.status === 200 && a.body.status === 'already_solved') || (b.status === 200 && b.body.status === 'already_solved'); expect(solved).toBe(true); expect(already).toBe(true); const rows = await solveRepo.count({ where: { challengeId: c.id, userId: u.id } }); expect(rows).toBe(1); }); it('awarded_points on the immutable solve row equals base + bonus and never changes after insert', async () => { const row = await solveRepo.findOne({ where: { challengeId: challenge.id, userId: playerId } }); expect(row).toBeTruthy(); expect(row!.pointsAwarded).toBe(row!.basePoints + row!.rankBonus); expect(row!.pointsAwarded).toBe(515); expect(row!.rankBonus).toBe(15); expect(row!.isFirst).toBe(1); }); it('emits an SSE solve payload with live_points + scoring params via SseHubService', async () => { const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service'); const hub = app.get(SseHubService); const seen: any[] = []; const sub = hub.scoreboard$().subscribe((p) => seen.push(p)); try { // Fresh challenge to make math deterministic. const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; const c = await challengeRepo.save(challengeRepo.create({ id: uuid(), name: 'sse-emit', descriptionMd: '', categoryId: cry.id, difficulty: 'LOW', initialPoints: 400, minimumPoints: 100, decaySolves: 4, flag: 'flag{sse}', protocol: 'WEB', port: null, ipAddress: '', enabled: true, })); await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{sse}'); const matching = seen.filter((p) => p.challengeId === c.id); expect(matching.length).toBeGreaterThan(0); const latest = matching[matching.length - 1]; expect(latest).toMatchObject({ topic: 'solve', challengeId: c.id, playerId: playerId, position: 1, awardedPoints: 400 + 15, rankBonus: 15, livePoints: 325, // post-insert: count=1, round(400 - 300 * 1/4) = 325 solveCount: 1, initialPoints: 400, minimumPoints: 100, decaySolves: 4, }); expect(typeof latest.playerName).toBe('string'); expect(typeof latest.awardedAtUtc).toBe('string'); } finally { sub.unsubscribe(); } }); it('emits the solve payload exactly once for a fresh insert (no duplicate publishes on already_solved retry)', async () => { const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service'); const hub = app.get(SseHubService); const seen: any[] = []; const sub = hub.scoreboard$().subscribe((p) => seen.push(p)); try { const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; const c = await challengeRepo.save(challengeRepo.create({ id: uuid(), name: 'once-only', descriptionMd: '', categoryId: cry.id, difficulty: 'LOW', initialPoints: 100, minimumPoints: 50, decaySolves: 5, flag: 'flag{once}', protocol: 'WEB', port: null, ipAddress: '', enabled: true, })); // Subscribe AFTER subscribing to ensure we don't miss the publish. const before = seen.filter((p) => p.challengeId === c.id).length; await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}'); // Retry: should be idempotent and NOT publish. await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}'); await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}'); const after = seen.filter((p) => p.challengeId === c.id).length; expect(after - before).toBe(1); } finally { sub.unsubscribe(); } }); });