AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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<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());
|
||||
}
|
||||
|
||||
async function setStoppedWindow(svc: SettingsService): Promise<void> {
|
||||
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<ChallengeEntity>;
|
||||
let solveRepo: Repository<SolveEntity>;
|
||||
let userRepo: Repository<UserEntity>;
|
||||
let categoryRepo: Repository<CategoryEntity>;
|
||||
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<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||
solveRepo = app.get<Repository<SolveEntity>>(getRepositoryToken(SolveEntity));
|
||||
userRepo = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
|
||||
categoryRepo = app.get<Repository<CategoryEntity>>(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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user