269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
process.env.DATABASE_PATH = ':memory:';
|
|
process.env.THEMES_DIR = './themes';
|
|
process.env.FRONTEND_DIST = './frontend/dist';
|
|
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-board-test';
|
|
|
|
import { Test } from '@nestjs/testing';
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import { HttpAdapterHost } from '@nestjs/core';
|
|
import cookieParser from 'cookie-parser';
|
|
import * as express from 'express';
|
|
import request from 'supertest';
|
|
import { CookieAccessInfo } from 'cookiejar';
|
|
import { v4 as uuid } from 'uuid';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { AppModule } from '../../backend/src/app.module';
|
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { initDb } from './db-helper';
|
|
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
|
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
|
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
|
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
|
|
|
async function setRunningWindow(svc: SettingsService): Promise<void> {
|
|
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString());
|
|
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
|
|
}
|
|
|
|
describe('GET /api/v1/challenges/board (authenticated)', () => {
|
|
let app: INestApplication;
|
|
let adminToken: string;
|
|
let adminCsrf: string;
|
|
let settings: SettingsService;
|
|
let challengeRepo: Repository<ChallengeEntity>;
|
|
let categoryRepo: Repository<CategoryEntity>;
|
|
let cry: CategoryEntity;
|
|
let web: CategoryEntity;
|
|
|
|
beforeAll(async () => {
|
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
app = moduleRef.createNestApplication();
|
|
app.use(cookieParser());
|
|
app.use(express.json({ limit: '1mb' }));
|
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
|
await app.init();
|
|
await initDb(app);
|
|
|
|
settings = app.get(SettingsService);
|
|
challengeRepo = app.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
|
categoryRepo = app.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
|
|
|
cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
|
web = (await categoryRepo.findOne({ where: { systemKey: 'WEB' } }))!;
|
|
if (!cry || !web) throw new Error('expected CRY and WEB seeded categories');
|
|
|
|
// Seed: 2 challenges under CRY (LOW, HIGH) and 1 under WEB (MEDIUM); one disabled.
|
|
await challengeRepo.save([
|
|
challengeRepo.create({
|
|
id: uuid(),
|
|
name: 'alphacipher',
|
|
descriptionMd: '# Alpha\n',
|
|
categoryId: cry.id,
|
|
difficulty: 'LOW',
|
|
initialPoints: 200,
|
|
minimumPoints: 100,
|
|
decaySolves: 5,
|
|
flag: 'flag{alpha}',
|
|
protocol: 'WEB',
|
|
port: null,
|
|
ipAddress: '',
|
|
enabled: true,
|
|
}),
|
|
challengeRepo.create({
|
|
id: uuid(),
|
|
name: 'Zeta-key',
|
|
descriptionMd: '# Zeta\n',
|
|
categoryId: cry.id,
|
|
difficulty: 'HIGH',
|
|
initialPoints: 500,
|
|
minimumPoints: 100,
|
|
decaySolves: 10,
|
|
flag: 'flag{zeta}',
|
|
protocol: 'WEB',
|
|
port: null,
|
|
ipAddress: '',
|
|
enabled: true,
|
|
}),
|
|
challengeRepo.create({
|
|
id: uuid(),
|
|
name: 'webmid',
|
|
descriptionMd: '# web\n',
|
|
categoryId: web.id,
|
|
difficulty: 'MEDIUM',
|
|
initialPoints: 300,
|
|
minimumPoints: 50,
|
|
decaySolves: 5,
|
|
flag: 'flag{web}',
|
|
protocol: 'WEB',
|
|
port: null,
|
|
ipAddress: '',
|
|
enabled: true,
|
|
}),
|
|
challengeRepo.create({
|
|
id: uuid(),
|
|
name: 'hidden',
|
|
descriptionMd: 'hidden',
|
|
categoryId: cry.id,
|
|
difficulty: 'LOW',
|
|
initialPoints: 100,
|
|
minimumPoints: 50,
|
|
decaySolves: 5,
|
|
flag: 'flag{hidden}',
|
|
protocol: 'WEB',
|
|
port: null,
|
|
ipAddress: '',
|
|
enabled: false,
|
|
}),
|
|
]);
|
|
|
|
await setRunningWindow(settings);
|
|
|
|
// Bootstrap admin and login.
|
|
const server = app.getHttpServer();
|
|
await request(server).post('/api/v1/auth/register-first-admin')
|
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
|
.expect(201);
|
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
|
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
|
const login = await request(server).post('/api/v1/auth/login')
|
|
.set('Cookie', `csrf=${adminCsrf}`)
|
|
.set('X-CSRF-Token', adminCsrf)
|
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
|
.expect(201);
|
|
adminToken = login.body.accessToken;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
await request(app.getHttpServer()).get('/api/v1/challenges/board').expect(401);
|
|
});
|
|
|
|
it('returns columns sorted by abbreviation with cards sorted by difficulty then name', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/v1/challenges/board')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.expect(200);
|
|
expect(Array.isArray(res.body.columns)).toBe(true);
|
|
const abbrs = res.body.columns.map((c: any) => c.abbreviation);
|
|
expect(abbrs).toEqual([...abbrs].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())));
|
|
|
|
const cryCol = res.body.columns.find((c: any) => c.abbreviation === 'CRY');
|
|
expect(cryCol).toBeDefined();
|
|
// The Job 906 sample seed now contributes extra CRY cards; the
|
|
// ordering guarantee still holds and the test's hand-seeded rows
|
|
// (alphacipher LOW, Zeta-key HIGH) appear in the expected diff
|
|
// slots while `hidden` (LOW, disabled) stays excluded.
|
|
const difficulties = cryCol.cards.map((card: any) => card.difficulty);
|
|
expect(difficulties[0]).toBe('LOW');
|
|
expect(difficulties[difficulties.length - 1]).toBe('HIGH');
|
|
const names = cryCol.cards.map((card: any) => String(card.name).toLowerCase());
|
|
expect(names).toContain('alphacipher');
|
|
expect(names).toContain('zeta-key');
|
|
expect(names).not.toContain('hidden');
|
|
|
|
const enabledCardIds = res.body.columns.flatMap((c: any) => c.cards.map((card: any) => card.id));
|
|
// Seeded + manually inserted enabled cards, with `hidden` disabled and excluded.
|
|
expect(enabledCardIds.length).toBeGreaterThanOrEqual(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 firstCard: any = list.body.columns[0].cards[0];
|
|
const cardId: string = firstCard.id;
|
|
// Pick a known seeded flag for the first CRY card. Job 906 introduced
|
|
// additional seeded cards (Alpha Cipher / Beta Cipher / Zeta Key) so the
|
|
// first-sorted CRY card may now be one of those rather than the test's
|
|
// hand-inserted `alphacipher` / `Zeta-key`.
|
|
const firstName: string = String(firstCard.name).toLowerCase();
|
|
let flagToSubmit: string;
|
|
if (firstName === 'alphacipher' || firstName === 'alpha cipher') {
|
|
flagToSubmit = 'flag{alpha}';
|
|
} else if (firstName === 'zeta-key' || firstName === 'zeta key') {
|
|
flagToSubmit = 'flag{zeta}';
|
|
} else if (firstName === 'beta cipher') {
|
|
flagToSubmit = 'flag{beta}';
|
|
} else {
|
|
flagToSubmit = 'flag{alpha}';
|
|
}
|
|
// 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: flagToSubmit })
|
|
.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{');
|
|
});
|
|
});
|