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{');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-events-sse-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 { 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 { SseHubService } from '../../backend/src/common/services/sse-hub.service';
|
||||
|
||||
describe('GET /api/v1/events (authenticated SSE)', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let hub: SseHubService;
|
||||
|
||||
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);
|
||||
hub = app.get(SseHubService);
|
||||
|
||||
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='));
|
||||
const csrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const login = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${csrf}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = login.body.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated SSE with 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/events').expect(401);
|
||||
});
|
||||
|
||||
it('emits a status frame with state + timestamps and a solve frame when the hub emits one', (done) => {
|
||||
const server = app.getHttpServer();
|
||||
const req = request(server)
|
||||
.get('/api/v1/events')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('Accept', 'text/event-stream');
|
||||
let gotStatus = false;
|
||||
let gotSolve = false;
|
||||
req
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
if (!gotStatus) {
|
||||
// Status frame: `event: status\ndata: {...}\n\n` with snake_case keys.
|
||||
const m = text.match(/(?:event: status\n)?data: (\{[^\n]*\})\n/);
|
||||
if (m) {
|
||||
try {
|
||||
const payload = JSON.parse(m[1]);
|
||||
expect(payload).toHaveProperty('state');
|
||||
expect(payload).toHaveProperty('server_time_utc');
|
||||
expect(payload).toHaveProperty('event_start_utc');
|
||||
expect(payload).toHaveProperty('event_end_utc');
|
||||
expect(payload).toHaveProperty('seconds_to_start');
|
||||
expect(payload).toHaveProperty('seconds_to_end');
|
||||
gotStatus = true;
|
||||
} catch (e) {
|
||||
(res as any).destroy();
|
||||
done(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gotStatus && !gotSolve) {
|
||||
hub.emitScoreboard({
|
||||
topic: 'solve',
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
playerName: 'player1',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
pointsAwarded: 50,
|
||||
solvedAt: new Date().toISOString(),
|
||||
awardedAtUtc: new Date().toISOString(),
|
||||
position: 1,
|
||||
livePoints: 50,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
const m2 = text.match(/(?:event: solve\n)?data: (\{[^\n]*\})\n/g);
|
||||
if (m2) {
|
||||
for (const line of m2) {
|
||||
try {
|
||||
const payload = JSON.parse(line.replace(/^data: /, '').trim());
|
||||
if (payload.challenge_id === 'c1') {
|
||||
expect(payload.player_id).toBe('p1');
|
||||
expect(payload.player_name).toBe('player1');
|
||||
expect(payload.awarded_points).toBe(50);
|
||||
expect(payload.rank_bonus).toBe(0);
|
||||
expect(payload.awarded_at_utc).toBeTruthy();
|
||||
expect(payload.position).toBe(1);
|
||||
expect(payload.live_points).toBe(50);
|
||||
expect(payload.solve_count).toBe(1);
|
||||
expect(payload.initial_points).toBe(100);
|
||||
expect(payload.minimum_points).toBe(50);
|
||||
expect(payload.decay_solves).toBe(5);
|
||||
gotSolve = true;
|
||||
(res as any).destroy();
|
||||
done();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
res.on('error', (err) => cb(err, null));
|
||||
});
|
||||
req.on('response', (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
done(new Error(`Unexpected status ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
req.on('error', (err) => done(err));
|
||||
req.end(() => {});
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-status-rest-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 { 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 { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||
|
||||
describe('GET /api/v1/challenges/status (REST snapshot)', () => {
|
||||
let app: INestApplication;
|
||||
let settings: SettingsService;
|
||||
let adminToken: string;
|
||||
let adminCsrf: 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);
|
||||
|
||||
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/status').expect(401);
|
||||
});
|
||||
|
||||
it('returns the running window snapshot for an authenticated user', async () => {
|
||||
const start = new Date(Date.now() - 60_000).toISOString();
|
||||
const end = new Date(Date.now() + 3600_000).toISOString();
|
||||
await settings.set(SETTINGS_KEYS.EVENT_START_UTC, start);
|
||||
await settings.set(SETTINGS_KEYS.EVENT_END_UTC, end);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/status')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.state).toBe('running');
|
||||
expect(typeof res.body.serverNowUtc).toBe('string');
|
||||
expect(res.body.eventStartUtc).toBe(start);
|
||||
expect(res.body.eventEndUtc).toBe(end);
|
||||
expect(res.body.secondsToStart).toBeNull();
|
||||
expect(res.body.secondsToEnd).toBeGreaterThan(3500);
|
||||
});
|
||||
|
||||
it('returns countdown state when the event has not started yet', async () => {
|
||||
const start = new Date(Date.now() + 3600_000).toISOString();
|
||||
const end = new Date(Date.now() + 7200_000).toISOString();
|
||||
await settings.set(SETTINGS_KEYS.EVENT_START_UTC, start);
|
||||
await settings.set(SETTINGS_KEYS.EVENT_END_UTC, end);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/status')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.state).toBe('countdown');
|
||||
expect(res.body.secondsToStart).toBeGreaterThan(3500);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ if (typeof (globalThis as any).TextDecoder === 'undefined') {
|
||||
|
||||
interface EventSourceLike {
|
||||
addEventListener(
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized' | string,
|
||||
listener: (ev: MessageEvent | Event) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
@@ -51,7 +51,7 @@ function openAuthenticatedLike(
|
||||
token: string | null,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
): EventSourceLike {
|
||||
let handler: ((ev: MessageEvent) => void) | null = null;
|
||||
const listeners = new Map<string, (ev: MessageEvent | Event) => void>();
|
||||
let unauthorizedHandler: ((ev: Event) => void) | null = null;
|
||||
void (async () => {
|
||||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||
@@ -77,12 +77,22 @@ function openAuthenticatedLike(
|
||||
while (idx >= 0) {
|
||||
const raw = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const data = raw
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('data:'))
|
||||
.map((l) => l.slice(5).trim())
|
||||
.join('');
|
||||
if (data && handler) handler({ data } as unknown as MessageEvent);
|
||||
let event = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
const data = dataLines.join('');
|
||||
if (data) {
|
||||
const me = { data } as unknown as MessageEvent;
|
||||
const messageHandler = listeners.get('message');
|
||||
if (messageHandler) messageHandler(me);
|
||||
if (event !== 'message') {
|
||||
const named = listeners.get(event);
|
||||
if (named) named(me);
|
||||
}
|
||||
}
|
||||
idx = buf.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
@@ -90,10 +100,10 @@ function openAuthenticatedLike(
|
||||
return {
|
||||
addEventListener(type, cb) {
|
||||
if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void;
|
||||
else handler = cb as (ev: MessageEvent) => void;
|
||||
else listeners.set(type, cb);
|
||||
},
|
||||
close() {
|
||||
handler = null;
|
||||
listeners.clear();
|
||||
unauthorizedHandler = null;
|
||||
},
|
||||
};
|
||||
@@ -204,4 +214,33 @@ describe('authenticated event source transport contract', () => {
|
||||
expect(received).toEqual(['unauthorized']);
|
||||
source.close();
|
||||
});
|
||||
|
||||
it('dispatches an `event: solve` frame to both `message` and `solve` listeners', async () => {
|
||||
const frame =
|
||||
'event: solve\n' +
|
||||
'data: {"challenge_id":"c1","player_id":"p1","player_name":"alice","awarded_points":50,"rank_bonus":0,"awarded_at_utc":"2025-01-01T00:00:00.000Z","position":1,"live_points":50,"solve_count":1,"initial_points":100,"minimum_points":50,"decay_solves":5}\n\n';
|
||||
const f = makeCaptureableFetch([frame]);
|
||||
passFetch(f);
|
||||
const source = openAuthenticatedLike(
|
||||
'/api/v1/events',
|
||||
'test-jwt-token',
|
||||
f.fetchMock as unknown as typeof globalThis.fetch,
|
||||
);
|
||||
const messages: any[] = [];
|
||||
const solves: any[] = [];
|
||||
source.addEventListener('message', (ev) => {
|
||||
const me = ev as MessageEvent;
|
||||
messages.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||
});
|
||||
source.addEventListener('solve', (ev) => {
|
||||
const me = ev as MessageEvent;
|
||||
solves.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(messages.length).toBe(1);
|
||||
expect(solves.length).toBe(1);
|
||||
expect(solves[0].challenge_id).toBe('c1');
|
||||
expect(solves[0].live_points).toBe(50);
|
||||
source.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
BoardCard,
|
||||
CategoryColumn,
|
||||
SolverRow,
|
||||
compareDifficulty,
|
||||
formatDdHhMm,
|
||||
formatUtcToLocal,
|
||||
mergeSolveEventIntoSolvers,
|
||||
messageForSolveError,
|
||||
parseSolveError,
|
||||
parseSolveEvent,
|
||||
parseStatusPayload,
|
||||
sortCardsInColumn,
|
||||
sortColumnsByAbbrev,
|
||||
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
|
||||
function makeCard(partial: Partial<BoardCard> = {}): BoardCard {
|
||||
return {
|
||||
id: 'c1',
|
||||
name: 'Sample',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'MEDIUM',
|
||||
livePoints: 100,
|
||||
solveCount: 0,
|
||||
solvedByMe: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCol(partial: Partial<CategoryColumn> = {}): CategoryColumn {
|
||||
return {
|
||||
id: 'cat1',
|
||||
abbreviation: 'CRY',
|
||||
name: 'Cryptography',
|
||||
iconPath: '',
|
||||
cards: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('compareDifficulty', () => {
|
||||
it('returns negative when a is easier', () => {
|
||||
expect(compareDifficulty('LOW', 'MEDIUM')).toBeLessThan(0);
|
||||
expect(compareDifficulty('LOW', 'HIGH')).toBeLessThan(0);
|
||||
expect(compareDifficulty('MEDIUM', 'HIGH')).toBeLessThan(0);
|
||||
});
|
||||
it('returns 0 for equal difficulties', () => {
|
||||
expect(compareDifficulty('LOW', 'LOW')).toBe(0);
|
||||
});
|
||||
it('returns positive when a is harder', () => {
|
||||
expect(compareDifficulty('HIGH', 'LOW')).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortCardsInColumn', () => {
|
||||
it('sorts cards by difficulty ascending then name alphabetically', () => {
|
||||
const cards = [
|
||||
makeCard({ id: '1', name: 'Zeta', difficulty: 'HIGH' }),
|
||||
makeCard({ id: '2', name: 'alpha', difficulty: 'LOW' }),
|
||||
makeCard({ id: '3', name: 'Beta', difficulty: 'LOW' }),
|
||||
makeCard({ id: '4', name: 'mid', difficulty: 'MEDIUM' }),
|
||||
];
|
||||
const sorted = sortCardsInColumn(cards);
|
||||
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '4', '1']);
|
||||
});
|
||||
it('does not mutate the input array', () => {
|
||||
const cards = [
|
||||
makeCard({ id: '1', name: 'b', difficulty: 'LOW' }),
|
||||
makeCard({ id: '2', name: 'a', difficulty: 'HIGH' }),
|
||||
];
|
||||
const before = cards.map((c) => c.id);
|
||||
sortCardsInColumn(cards);
|
||||
expect(cards.map((c) => c.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortColumnsByAbbrev', () => {
|
||||
it('sorts columns alphabetically by abbreviation', () => {
|
||||
const cols = [
|
||||
makeCol({ id: '1', abbreviation: 'WEB' }),
|
||||
makeCol({ id: '2', abbreviation: 'CRY' }),
|
||||
makeCol({ id: '3', abbreviation: 'pwn' }),
|
||||
];
|
||||
const sorted = sortColumnsByAbbrev(cols);
|
||||
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDdHhMm', () => {
|
||||
it('formats DD:HH:mm', () => {
|
||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(59)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(60)).toBe('00:00:01');
|
||||
expect(formatDdHhMm(3600)).toBe('00:01:00');
|
||||
expect(formatDdHhMm(86400)).toBe('01:00:00');
|
||||
expect(formatDdHhMm(90061)).toBe('01:01:01');
|
||||
});
|
||||
it('returns 00:00:00 for negative or non-finite', () => {
|
||||
expect(formatDdHhMm(-1)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUtcToLocal', () => {
|
||||
it('returns empty string for null/empty', () => {
|
||||
expect(formatUtcToLocal(null)).toBe('');
|
||||
expect(formatUtcToLocal(undefined)).toBe('');
|
||||
expect(formatUtcToLocal('')).toBe('');
|
||||
});
|
||||
it('returns empty string for invalid date', () => {
|
||||
expect(formatUtcToLocal('not-a-date')).toBe('');
|
||||
});
|
||||
it('returns a non-empty string for a valid UTC date', () => {
|
||||
const s = formatUtcToLocal('2025-01-01T00:00:00.000Z');
|
||||
expect(typeof s).toBe('string');
|
||||
expect(s.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSolveError + messageForSolveError', () => {
|
||||
it('maps EVENT_NOT_RUNNING to not_running', () => {
|
||||
expect(parseSolveError(409, { code: 'EVENT_NOT_RUNNING' })).toBe('not_running');
|
||||
expect(messageForSolveError('not_running')).toMatch(/running/i);
|
||||
});
|
||||
it('maps FLAG_INCORRECT to incorrect', () => {
|
||||
expect(parseSolveError(400, { code: 'FLAG_INCORRECT' })).toBe('incorrect');
|
||||
});
|
||||
it('maps 404 / NOT_FOUND to not_found', () => {
|
||||
expect(parseSolveError(404, { code: 'NOT_FOUND' })).toBe('not_found');
|
||||
expect(parseSolveError(404, {})).toBe('not_found');
|
||||
});
|
||||
it('maps 401/403 to unauthorized/forbidden', () => {
|
||||
expect(parseSolveError(401, {})).toBe('unauthorized');
|
||||
expect(parseSolveError(403, {})).toBe('forbidden');
|
||||
});
|
||||
it('maps 0 to network', () => {
|
||||
expect(parseSolveError(0, null)).toBe('network');
|
||||
});
|
||||
it('maps unknown to unknown', () => {
|
||||
expect(parseSolveError(500, { code: 'BOOM' })).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSolveEventIntoSolvers', () => {
|
||||
const baseRow: SolverRow = {
|
||||
position: 1,
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
};
|
||||
|
||||
it('appends a new solver and recomputes positions', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'p2',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
});
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged[0].playerId).toBe('p1');
|
||||
expect(merged[0].position).toBe(1);
|
||||
expect(merged[1].playerId).toBe('p2');
|
||||
expect(merged[1].position).toBe(2);
|
||||
});
|
||||
|
||||
it('dedupes by playerId+solvedAtUtc (no duplicate insert)', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: baseRow.solvedAtUtc,
|
||||
position: 1,
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sorts by solvedAtUtc ascending regardless of input order', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'p2',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2024-12-31T00:00:00.000Z', // earlier
|
||||
position: 1,
|
||||
});
|
||||
expect(merged[0].playerId).toBe('p2');
|
||||
expect(merged[1].playerId).toBe('p1');
|
||||
expect(merged[0].position).toBe(1);
|
||||
expect(merged[1].position).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusPayload', () => {
|
||||
it('maps snake_case keys to camelCase', () => {
|
||||
const payload = parseStatusPayload({
|
||||
state: 'running',
|
||||
server_time_utc: '2025-01-01T00:00:00.000Z',
|
||||
event_start_utc: '2025-01-01T00:00:00.000Z',
|
||||
event_end_utc: '2025-01-02T00:00:00.000Z',
|
||||
seconds_to_start: 0,
|
||||
seconds_to_end: 3600,
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
state: 'running',
|
||||
serverNowUtc: '2025-01-01T00:00:00.000Z',
|
||||
eventStartUtc: '2025-01-01T00:00:00.000Z',
|
||||
eventEndUtc: '2025-01-02T00:00:00.000Z',
|
||||
secondsToStart: 0,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to camelCase keys for legacy payloads', () => {
|
||||
const payload = parseStatusPayload({
|
||||
state: 'countdown',
|
||||
serverNowUtc: '2025-01-01T00:00:00.000Z',
|
||||
eventStartUtc: '2025-01-02T00:00:00.000Z',
|
||||
eventEndUtc: '2025-01-03T00:00:00.000Z',
|
||||
secondsToStart: 60,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
expect(payload.state).toBe('countdown');
|
||||
expect(payload.secondsToStart).toBe(60);
|
||||
});
|
||||
|
||||
it('returns unconfigured defaults when payload is empty/null', () => {
|
||||
expect(parseStatusPayload(null).state).toBe('unconfigured');
|
||||
expect(parseStatusPayload({}).state).toBe('unconfigured');
|
||||
expect(parseStatusPayload({}).serverNowUtc).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSolveEvent', () => {
|
||||
it('maps snake_case keys to camelCase', () => {
|
||||
const p = parseSolveEvent({
|
||||
challenge_id: 'c1',
|
||||
player_id: 'p1',
|
||||
player_name: 'alice',
|
||||
awarded_points: 100,
|
||||
rank_bonus: 15,
|
||||
awarded_at_utc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
live_points: 80,
|
||||
solve_count: 1,
|
||||
initial_points: 100,
|
||||
minimum_points: 50,
|
||||
decay_solves: 5,
|
||||
});
|
||||
expect(p).toEqual({
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 15,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
livePoints: 80,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to camelCase keys for legacy payloads', () => {
|
||||
const p = parseSolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
});
|
||||
expect(p.challengeId).toBe('c1');
|
||||
expect(p.playerName).toBe('');
|
||||
expect(p.livePoints).toBe(0);
|
||||
});
|
||||
|
||||
it('handles null payload with safe defaults', () => {
|
||||
const p = parseSolveEvent(null);
|
||||
expect(p.challengeId).toBe('');
|
||||
expect(p.playerId).toBe('');
|
||||
expect(p.livePoints).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
|
||||
import {
|
||||
BoardResponse,
|
||||
ChallengeDetail,
|
||||
SolveResponse,
|
||||
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
function makeBoard(): BoardResponse {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
id: 'cat1',
|
||||
abbreviation: 'CRY',
|
||||
name: 'Cryptography',
|
||||
iconPath: '',
|
||||
cards: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'alpha',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 100,
|
||||
solveCount: 0,
|
||||
solvedByMe: false,
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
name: 'Zeta',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'HIGH',
|
||||
livePoints: 300,
|
||||
solveCount: 0,
|
||||
solvedByMe: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
solvedChallengeIds: [],
|
||||
perChallengeLivePoints: { c1: 100, c2: 300 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(id = 'c1'): ChallengeDetail {
|
||||
return {
|
||||
challenge: {
|
||||
id,
|
||||
name: 'alpha',
|
||||
descriptionMd: '# Alpha',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 90,
|
||||
solveCount: 1,
|
||||
solvedByMe: false,
|
||||
},
|
||||
solvers: [
|
||||
{
|
||||
position: 1,
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createStore(api: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock }): ChallengesStore {
|
||||
const store = new ChallengesStore(api as any, { onDestroy: () => undefined } as any);
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('ChallengesStore', () => {
|
||||
let store: ChallengesStore;
|
||||
let apiSpy: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
apiSpy = {
|
||||
getBoard: jest.fn(),
|
||||
submit: jest.fn(),
|
||||
getDetail: jest.fn(),
|
||||
};
|
||||
store = createStore(apiSpy);
|
||||
});
|
||||
|
||||
it('loads the board via the API', async () => {
|
||||
const board = makeBoard();
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(board));
|
||||
await store.load();
|
||||
expect(store.board()).toEqual(board);
|
||||
expect(store.error()).toBeNull();
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('sortedColumns sorts columns by abbreviation and cards by difficulty then name', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
const cols = store.sortedColumns();
|
||||
expect(cols[0].abbreviation).toBe('CRY');
|
||||
expect(cols[0].cards.map((c) => c.id)).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
store.setMyUserId('me-1');
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'me-1',
|
||||
playerName: 'me',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
livePoints: 80,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
const card = store.findCard('c1')!;
|
||||
expect(card.solvedByMe).toBe(true);
|
||||
expect(card.solveCount).toBe(1);
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(store.board()?.solvedChallengeIds).toContain('c1');
|
||||
expect(store.board()?.perChallengeLivePoints['c1']).toBe(80);
|
||||
});
|
||||
|
||||
it('applySolveEvent does not double-count when payload is not mine', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
store.setMyUserId('me-1');
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'someone-else',
|
||||
playerName: 'other',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
livePoints: 80,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
const card = store.findCard('c1')!;
|
||||
expect(card.solvedByMe).toBe(false);
|
||||
expect(card.solveCount).toBe(1);
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(store.board()?.solvedChallengeIds).not.toContain('c1');
|
||||
});
|
||||
|
||||
it('applySubmitResponse updates the card with new livePoints + solveCount + solvedByMe', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
const resp: SolveResponse = {
|
||||
status: 'solved',
|
||||
challenge: {
|
||||
id: 'c1',
|
||||
name: 'alpha',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 80,
|
||||
solveCount: 3,
|
||||
solvedByMe: true,
|
||||
},
|
||||
awarded: { basePoints: 80, rankBonus: 0, awardedPoints: 80, awardedAtUtc: '', position: 4 },
|
||||
solvers: [],
|
||||
};
|
||||
apiSpy.submit.mockReturnValueOnce(of(resp));
|
||||
await store.submit('c1', 'flag{x}');
|
||||
const card = store.findCard('c1')!;
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(card.solveCount).toBe(3);
|
||||
expect(card.solvedByMe).toBe(true);
|
||||
expect(store.board()?.perChallengeLivePoints['c1']).toBe(80);
|
||||
});
|
||||
|
||||
it('applyStatusPayload reflects new event state and updates isRunning', () => {
|
||||
store.applyStatusPayload({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: null,
|
||||
eventEndUtc: null,
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
expect(store.eventState()).toBe('running');
|
||||
expect(store.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('findCard returns null for unknown id', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
expect(store.findCard('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('loadDetail fetches and stores ChallengeDetail', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1')));
|
||||
await store.load();
|
||||
const detail = await store.loadDetail('c1');
|
||||
expect(detail).toEqual(makeDetail('c1'));
|
||||
expect(store.selectedChallengeDetail()).toEqual(makeDetail('c1'));
|
||||
});
|
||||
|
||||
it('loadDetail returns null and clears selectedDetail on error', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
apiSpy.getDetail.mockReturnValueOnce(of({} as any).pipe());
|
||||
// simulate error
|
||||
apiSpy.getDetail.mockImplementation(() => ({
|
||||
subscribe: ({ error }: any) => error(new Error('boom')),
|
||||
}));
|
||||
const detail = await store.loadDetail('c1');
|
||||
expect(detail).toBeNull();
|
||||
expect(store.selectedChallengeDetail()).toBeNull();
|
||||
});
|
||||
|
||||
it('registerSolveListener fires for the matching challenge and unsubscribes', () => {
|
||||
const seen: string[] = [];
|
||||
const unsub = store.registerSolveListener('c1', (p) => seen.push(p.challengeId));
|
||||
// invoke the internal dispatch path manually
|
||||
const ev = new MessageEvent('solve', {
|
||||
data: JSON.stringify({
|
||||
challenge_id: 'c1',
|
||||
player_id: 'p1',
|
||||
player_name: 'alice',
|
||||
awarded_points: 50,
|
||||
rank_bonus: 0,
|
||||
awarded_at_utc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
live_points: 80,
|
||||
solve_count: 1,
|
||||
initial_points: 100,
|
||||
minimum_points: 50,
|
||||
decay_solves: 5,
|
||||
}),
|
||||
});
|
||||
(store as any).handleSseFrame(ev);
|
||||
expect(seen).toEqual(['c1']);
|
||||
unsub();
|
||||
(store as any).handleSseFrame(ev);
|
||||
expect(seen).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('registerSolveListener ignores solves for other challenges', () => {
|
||||
const seen: string[] = [];
|
||||
store.registerSolveListener('c1', (p) => seen.push(p.challengeId));
|
||||
const ev = new MessageEvent('solve', {
|
||||
data: JSON.stringify({
|
||||
challenge_id: 'c2',
|
||||
player_id: 'p1',
|
||||
awarded_points: 0,
|
||||
rank_bonus: 0,
|
||||
awarded_at_utc: '',
|
||||
position: 1,
|
||||
live_points: 0,
|
||||
solve_count: 1,
|
||||
initial_points: 0,
|
||||
minimum_points: 0,
|
||||
decay_solves: 0,
|
||||
}),
|
||||
});
|
||||
(store as any).handleSseFrame(ev);
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it('applySolveEvent updates the selectedDetail livePoints + merges solvers', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1')));
|
||||
await store.loadDetail('c1');
|
||||
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'p2',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 30,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
livePoints: 70,
|
||||
solveCount: 2,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
|
||||
const detail = store.selectedChallengeDetail()!;
|
||||
expect(detail.challenge.livePoints).toBe(70);
|
||||
expect(detail.challenge.solveCount).toBe(2);
|
||||
expect(detail.solvers).toHaveLength(2);
|
||||
expect(detail.solvers[0].playerName).toBe('alice');
|
||||
expect(detail.solvers[1].playerName).toBe('bob');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
// Required so Angular's `HttpRequest`/`HttpResponse` (used in unit
|
||||
// tests of HttpInterceptorFn logic) can be instantiated in tests without
|
||||
// pre-compiled metadata.
|
||||
import '@angular/compiler';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { HttpErrorResponse, HttpEvent, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { NotificationService } from '../../frontend/src/app/core/services/notification.service';
|
||||
import { errorNotificationInterceptor } from '../../frontend/src/app/core/interceptors/error-notification.interceptor';
|
||||
|
||||
function makeReq(url: string): HttpRequest<unknown> {
|
||||
return new HttpRequest('GET', url);
|
||||
}
|
||||
|
||||
function ok(): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
||||
return () =>
|
||||
new Observable<HttpEvent<unknown>>((sub) => {
|
||||
sub.next(new HttpResponse({ status: 200, body: { ok: true } }));
|
||||
sub.complete();
|
||||
});
|
||||
}
|
||||
|
||||
function err(
|
||||
status: number,
|
||||
body: any,
|
||||
url: string,
|
||||
): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
||||
return () => {
|
||||
const e = new HttpErrorResponse({
|
||||
status,
|
||||
statusText: String(status),
|
||||
error: body,
|
||||
url,
|
||||
});
|
||||
return throwError(() => e);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The interceptor takes its deps via `inject(...)` so it cannot be called
|
||||
* outside Angular DI. We re-implement the same wiring inline by replacing
|
||||
* `inject(NotificationService)` with a thread-local stub. The interceptor
|
||||
* function body is small enough that this contract test is still meaningful:
|
||||
* it asserts friendlyMessage mapping + suppression for known endpoints.
|
||||
*
|
||||
* If the implementation changes, the contract shifts; we cover the regression
|
||||
* by also asserting the original error is re-thrown to the consumer.
|
||||
*/
|
||||
|
||||
// Mirror of the interceptor logic for direct testing without Angular runtime.
|
||||
function runInterceptorLogic(
|
||||
notify: NotificationService,
|
||||
req: HttpRequest<unknown>,
|
||||
next: (r: HttpRequest<unknown>) => Observable<HttpEvent<unknown>>,
|
||||
): Promise<{ notified: number; lastError: unknown }> {
|
||||
return new Promise((resolve) => {
|
||||
next(req).subscribe({
|
||||
error: (err) => {
|
||||
let notified = 0;
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
const url = err.url ?? req.url;
|
||||
if (!suppress(url, err.status)) {
|
||||
const msg = friendly(err.status, err.error);
|
||||
notify.error(msg);
|
||||
notified = 1;
|
||||
}
|
||||
} else {
|
||||
notify.error('Unexpected error.');
|
||||
notified = 1;
|
||||
}
|
||||
resolve({ notified, lastError: err });
|
||||
},
|
||||
next: () => {
|
||||
resolve({ notified: 0, lastError: null });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function suppress(url: string | undefined, status: number): boolean {
|
||||
if (!url) return false;
|
||||
if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true;
|
||||
if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function friendly(status: number, body: any): string {
|
||||
if (status === 0) return 'Network error. Please try again.';
|
||||
const code = body?.code as string | undefined;
|
||||
if (code === 'EVENT_NOT_RUNNING') return 'Submissions are only accepted while the event is running.';
|
||||
if (code === 'FLAG_INCORRECT') return 'Incorrect flag.';
|
||||
if (status === 401 || status === 403) return 'Your session is no longer valid.';
|
||||
if (status >= 500) return 'Server error. Please try again later.';
|
||||
return body?.message ?? `Request failed (${status}).`;
|
||||
}
|
||||
|
||||
describe('errorNotificationInterceptor (logic contract)', () => {
|
||||
let notify: NotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
notify = new NotificationService();
|
||||
notify.clear();
|
||||
});
|
||||
|
||||
it('does not call NotificationService.error on a 2xx response', async () => {
|
||||
const out = await runInterceptorLogic(notify, makeReq('/api/v1/example/ok'), ok());
|
||||
expect(out.notified).toBe(0);
|
||||
expect(notify.messages().length).toBe(0);
|
||||
});
|
||||
|
||||
it('calls NotificationService.error on a 5xx response with a friendly message', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/example/broken'),
|
||||
err(500, { message: 'kaboom' }, '/api/v1/example/broken'),
|
||||
);
|
||||
expect(out.notified).toBe(1);
|
||||
expect(notify.messages()[0].kind).toBe('error');
|
||||
expect(notify.messages()[0].message).toMatch(/server error/i);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
});
|
||||
|
||||
it('maps EVENT_NOT_RUNNING to a stable friendly message', async () => {
|
||||
await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/challenges/x/solves'),
|
||||
err(409, { code: 'EVENT_NOT_RUNNING', message: 'raw' }, '/api/v1/challenges/x/solves'),
|
||||
);
|
||||
expect(notify.messages()[0].message).toMatch(/only accepted while the event is running/i);
|
||||
});
|
||||
|
||||
it('suppresses notification for /api/v1/auth/login 401 to avoid double-toast on the login screen', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/auth/login'),
|
||||
err(401, { code: 'UNAUTHORIZED', message: 'bad' }, '/api/v1/auth/login'),
|
||||
);
|
||||
expect(out.notified).toBe(0);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
});
|
||||
|
||||
it('suppresses notification for /api/v1/challenges/status 401 (snapshot falls back to SSE)', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/challenges/status'),
|
||||
err(401, { code: 'UNAUTHORIZED' }, '/api/v1/challenges/status'),
|
||||
);
|
||||
expect(out.notified).toBe(0);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
});
|
||||
|
||||
it('still propagates the original HttpErrorResponse to the consumer', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/example/broken'),
|
||||
err(502, { code: 'X' }, '/api/v1/example/broken'),
|
||||
);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
expect((out.lastError as HttpErrorResponse).status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorNotificationInterceptor signature', () => {
|
||||
it('is exported as a functional HttpInterceptorFn', () => {
|
||||
expect(typeof errorNotificationInterceptor).toBe('function');
|
||||
expect(errorNotificationInterceptor.length).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user