diff --git a/.kilo/plans/863-followup-2.md b/.kilo/plans/863-followup-2.md new file mode 100644 index 0000000..fcbc67d --- /dev/null +++ b/.kilo/plans/863-followup-2.md @@ -0,0 +1,162 @@ +# Implementation Plan: REST Event-State Snapshot, SSE De-dup, Solve Publish-Once, REST Error Notification + +## 0. Goal + +Four small refinements called out by the reviewer: + +1. Add a typed REST endpoint that returns the authoritative `EventStatePayload` (a one-shot snapshot) and call it from `challenges.page.ts` before/alongside `wireSse` so the page has state synchronously even if the SSE stream hasn't delivered its first frame yet. +2. In `challenges.store.ts`, stop double-dispatching SSE frames: register only the named `'solve'` listener (plus a one-time `'message'` listener for backward compat with `EventStatusStore` which still uses `'message'`). Move the `applyStatusPayload` call out of the store and back into the page's status wiring, since the status SSE is the responsibility of `EventStatusStore`, not the challenges store. +3. In `backend/src/modules/challenges/challenges.service.ts`, remove the two `publishSolveEvent(...)` calls that fire on `already_solved` (existing-row branch and unique-constraint recovery branch). Only the fresh-insert branch (currently line 414) should publish — duplicate broadcasts are noise since the row is unchanged. +4. Add a small functional REST error interceptor (`HttpInterceptorFn`) that pipes 4xx/5xx responses into a `NotificationService.error(...)` call so the modal/grid can surface failure state without bespoke try/catch everywhere. Register it in `frontend/src/main.ts`. + +## 1. Architectural Reconnaissance + +- **Backend controller surface for events**: `backend/src/modules/challenges/events.controller.ts` exposes `GET /api/v1/events` (SSE) and `backend/src/modules/system/system.controller.ts` exposes `GET /api/v1/events/status` (legacy SSE, plural-segmented) and a public `GET /api/v1/event/status` snapshot. There is no plain JSON REST snapshot for the event window under `/api/v1/challenges`. The new endpoint will live next to the challenges controller (`ChallengesController`). +- **DTO**: `EventStatusService.getState()` returns `EventStatePayload` which matches the snake_case status shape (`serverNowUtc`, `eventStartUtc`, `eventEndUtc`, `secondsToStart`, `secondsToEnd`). We reuse it. +- **Frontend SSE wiring today**: `ChallengesStore.wireSse` registers the same handler for `'message'`, `'solve'`, `'status'`. Because the backend SSE is well-formed (`event: solve` / `event: status`), the dispatcher in `AuthenticatedEventSourceService.parseAndDispatch` fires both `'message'` AND the named event — so the store's handler runs twice per frame. Fix: the challenges store only needs `'solve'` (to update cards + notify modal listeners); the `'status'` frame is consumed by `EventStatusStore` via its existing `'message'` listener (we'll keep that). +- **`EventStatusStore` SSE wiring**: it still uses `addEventListener('message', ...)` and ignores named events. That's correct for backward compat — it doesn't double-process frames. +- **REST error notification**: the app currently has no toast/notification service. We'll add a minimal `NotificationService` with an `error(message)` method that logs to console in non-browser contexts and a small DOM-mountable component is out of scope; the interceptor calls it and any future toast UI can subscribe to its signal. +- **Interceptors run in registration order on the request and reverse order on the response** (`withInterceptors([a, b])` → request runs a→b, response runs b→a). For a pure error inspector that wants to act on every response regardless of what auth/csrf did, we register it AFTER `authInterceptor` so it sees the final response on the way out. + +## 2. Impacted Files + +### Backend — To Modify +- `backend/src/modules/challenges/challenges.controller.ts` — add `@Get('status')` returning `EventStatePayload`. +- `backend/src/modules/challenges/challenges.service.ts` — remove two `publishSolveEvent(...)` calls in the existing-row branch and the constraint-recovery branch; keep the one in the fresh-insert branch. + +### Frontend — To Create +- `frontend/src/app/core/services/notification.service.ts` — minimal signal-based notification store with `error(message)`, `info(message)`, and a `messages` signal. +- `frontend/src/app/core/interceptors/error-notification.interceptor.ts` — `HttpInterceptorFn` that maps non-2xx responses to `NotificationService.error(...)`. + +### Frontend — To Modify +- `frontend/src/app/features/challenges/challenges.service.ts` — add `getEventState()` returning `Observable`. +- `frontend/src/app/features/challenges/challenges.store.ts` — expose `applyStatus(payload)` (already exists), change `wireSse` to register ONLY `'solve'` (drop `'message'` and `'status'` listeners in this store). Move the local handler logic so it doesn't process status frames. +- `frontend/src/app/features/challenges/challenges.page.ts` — call `store.getEventState()` once at boot (before `wireSse`) and pass the result to `eventStatus.applyServerStatus(payload)`; update `wireSse` listener if needed (no change required for status, since `EventStatusStore` handles it). +- `frontend/src/main.ts` — register `errorNotificationInterceptor` AFTER `authInterceptor`. + +### Tests — To Modify +- `tests/backend/challenges-submit-flag.spec.ts` — change the SSE-solve-payload test to subscribe once after a fresh insert; assert that two consecutive correct submissions yield ONE `emitScoreboard` call, not two. +- `tests/frontend/event-status.store.spec.ts` — add a small assertion that `currentServerTimeUtc` returns a valid ISO string and that `reloadAtCountdownZero` fires exactly once. +- New test file `tests/frontend/notification-interceptor.spec.ts` — assert the interceptor calls `NotificationService.error(...)` on a 500 response and does NOT call it on a 2xx response. + +## 3. Proposed Changes + +### Backend + +1. **`challenges.controller.ts`** — add (after the existing `board`/`detail`/`submit` handlers): + ```ts + @Get('status') + @ApiOperation({ summary: 'Authenticated event-window snapshot (REST JSON)' }) + async eventState(): Promise { + return this.statusSvc.getState(); + } + ``` + Inject `EventStatusService` into the controller (add to constructor). The existing `getState()` already returns `EventStatePayload`. This endpoint sits at `/api/v1/challenges/status`. We avoid `events/status` to not collide with the existing legacy SSE route. + +2. **`challenges.service.ts`** — remove the `publishSolveEvent(...)` call at line 294 (existing-row branch) and the one at line 368 (unique-constraint recovery branch). Keep only the line 414 call (fresh-insert path). The `publishSolveEvent` helper itself stays (it's still called from the fresh-insert path). + +### Frontend + +1. **`notification.service.ts`** (new): + ```ts + import { Injectable, signal } from '@angular/core'; + export interface Notification { id: number; kind: 'error' | 'info'; message: string; ts: number; } + @Injectable({ providedIn: 'root' }) + export class NotificationService { + private next = 1; + readonly messages = signal([]); + error(message: string): void { + this.push('error', message); + // eslint-disable-next-line no-console + console.error('[notification]', message); + } + info(message: string): void { + this.push('info', message); + // eslint-disable-next-line no-console + console.info('[notification]', message); + } + dismiss(id: number): void { + this.messages.update((cur) => cur.filter((m) => m.id !== id)); + } + private push(kind: Notification['kind'], message: string): void { + const n: Notification = { id: this.next++, kind, message, ts: Date.now() }; + this.messages.update((cur) => [...cur, n]); + } + } + ``` + +2. **`error-notification.interceptor.ts`** (new): + ```ts + import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; + import { inject } from '@angular/core'; + import { catchError, throwError } from 'rxjs'; + import { NotificationService } from '../services/notification.service'; + function friendlyMessage(status: number, body: any): string { + const code = body?.code as string | undefined; + const msg = (body?.message 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 === 0) return 'Network error. Please try again.'; + if (status === 401 || status === 403) return 'Your session is no longer valid.'; + if (status >= 500) return 'Server error. Please try again later.'; + return msg || `Request failed (${status}).`; + } + export const errorNotificationInterceptor: HttpInterceptorFn = (req, next) => { + const notify = inject(NotificationService); + return next(req).pipe( + catchError((err) => { + if (err instanceof HttpErrorResponse) { + notify.error(friendlyMessage(err.status, err.error)); + } else { + notify.error('Unexpected error.'); + } + return throwError(() => err); + }), + ); + }; + ``` + +3. **`challenges.service.ts`** — add: + ```ts + getEventState(): Observable { + return this.http + .get('/api/v1/challenges/status', { withCredentials: true }) + .pipe(catchError((err) => throwError(() => toEnvelope(err)))); + } + ``` + Add `EventStatePayload` to the import from `./challenges.pure`. + +4. **`challenges.store.ts`** — update `wireSse` to register only `'solve'`: + ```ts + const handler = (ev: MessageEvent | Event) => this.handleSseFrame(ev); + src.addEventListener('solve', handler); + ``` + Drop the `addEventListener('message', ...)` and `addEventListener('status', ...)` calls inside `wireSse`. `handleSseFrame` already short-circuits non-`'solve'` events by virtue of the `me.type === 'solve'` check at the top, so no further change needed inside that method. + +5. **`challenges.page.ts`** — at `ngOnInit`, before wiring the SSE: + ```ts + // REST snapshot first so the page has synchronous state. + this.store.getEventState().subscribe({ + next: (payload) => this.eventStatus.applyServerStatus(payload), + error: () => undefined, // SSE will deliver the next frame; don't notify. + }); + ``` + No structural change to `wireSse` — the challenges store now only listens for `'solve'`; `EventStatusStore` continues to listen for `'message'` on the same source (which the transport dispatches in addition to `'solve'`). + +6. **`main.ts`** — register the error interceptor last so it sees the final response: + ```ts + provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor])), + ``` + +## 4. Test Strategy + +- **Backend** (`tests/backend/challenges-submit-flag.spec.ts`): change the SSE-payload test to subscribe ONCE on `hub.scoreboard$()` and assert that two consecutive correct submissions (with an artificial constraint-recovery path forced via the existing concurrency test) emit only ONE payload. Add a new test that asserts `GET /api/v1/challenges/status` returns the running state with `server_now_utc` + `event_start_utc` + `event_end_utc` + `seconds_to_start` + `seconds_to_end` for an authenticated user. +- **Frontend** (`tests/frontend/event-status.store.spec.ts`): add `applyServerStatus` once, advance the tick, assert `currentServerTimeUtc()` is a valid ISO string and that `reloadAtCountdownZero` fires exactly once (not twice on consecutive ticks where `secondsToStart` is still 0). +- **Frontend** (`tests/frontend/notification-interceptor.spec.ts`, new): create an `HttpClient` via `provideHttpClient(withInterceptors([errorNotificationInterceptor]))` and a fake backend that returns 200 for one URL and 500 for another; assert `NotificationService.messages()` only contains the 500 message. +- All existing 504 tests must continue to pass. + +## 5. Notes / Trade-offs + +- We use a plain REST snapshot instead of "best-authenticated equivalent" of the public `/api/v1/event/status` because the public endpoint is unauthenticated and returns a slightly different shape (legacy `status` field, no `state` enum, only `countdownMs`). The new authenticated snapshot uses the canonical `EventStatePayload` shape and is reused by both the page bootstrap and (later) any admin tooling. +- The error interceptor only fires for HTTP errors thrown by Angular's `HttpClient`. The `ChallengesApiService` wraps these in its own `toEnvelope()` and re-throws, so the interceptor sees `HttpErrorResponse` and the service's catchError re-emits the envelope. We do not double-notify. +- Removing the two `publishSolveEvent` calls is safe because the fresh-insert path (line 414) is the only one that mutates state — the existing-row and constraint-recovery branches do not insert a new row, so emitting a "new solve" SSE frame for them is misleading to other connected clients (who would re-render an unchanged row). diff --git a/.kilo/plans/903.md b/.kilo/plans/903.md deleted file mode 100644 index 2619898..0000000 --- a/.kilo/plans/903.md +++ /dev/null @@ -1,94 +0,0 @@ -# Implementation Plan: Job 903 — Admin Challenges Import must Replace, Modal must Auto-Close - -## 1. Architectural Reconnaissance - -- **Codebase style & conventions:** - - Backend: NestJS (TypeScript, `strict`, class-based `Injectable` services, TypeORM with `better-sqlite3`, async/await, controllers delegating to services). - - Frontend: Angular 20 standalone components with `ChangeDetectionStrategy.OnPush`, signal `input()` / `output()`, `@if`/`@for` control flow, RxJS only for the search debounce, smart/dumb split. Pure helpers live in `*.pure.ts` and are tested directly without a TestBed. - - Tests: Jest + ts-jest configured in `tests/jest.config.js` as two projects (`backend`, `frontend`). Backend specs use `Test.createTestingModule` with in-memory SQLite and a unique `mkdtempSync` upload dir per file (no shared FS races). Frontend specs in `tests/frontend/` import pure helpers directly (no DOM) — the only TestBed specs are for components that must exercise `@Input` bindings. - - `npm test` at the repo root runs **all** tests for **all** components via the Jest project selector. - -- **Data Layer:** - - SQLite via TypeORM; relevant tables `challenge`, `challenge_file`, `category`, `solve`. - - File storage: `data/uploads/challenges/` for committed files, `.staging/` for in-flight uploads. - - Repository injection via `@InjectRepository(Entity)`; transactions via `this.dataSource.transaction(async (manager) => { ... })`. - -- **Test Framework & Structure:** - - Backend tests in `/repo/tests/backend/*.spec.ts` (Jest, `testEnvironment: 'node'`). - - Frontend tests in `/repo/tests/frontend/*.spec.ts` (Jest + `jsdom`); pure-helper specs do not need TestBed. - - Single command: `npm test` (already wired in root `package.json`). - - New specs to add: one backend spec for the replace-all-on-confirmed-import behavior, one frontend spec for the auto-close + list-refresh behavior of the import modal. Both must be runnable with `npm test`. - -- **Required Tools & Dependencies:** No new tools or packages. Existing `better-sqlite3`, TypeORM, NestJS, Angular, and Jest setup is sufficient. - -## 2. Impacted Files - -- **To Modify:** - - `backend/src/modules/admin/challenges.service.ts` — change `importConfirmed()` so it wipes every pre-existing `challenge` (and cascade-deletes their `challenge_file` + `solve` rows) before re-inserting from the document. Re-link the now-unreferenced disk files for unlink and unlink any orphaned file rows after the swap. - - `frontend/src/app/features/admin/challenges/challenges.component.ts` — after a successful confirmed import (`onImportConfirm`), call `closeImport()` so the dialog disappears (it currently stays open showing the result message and a manual Close button). - - `docs/guides/admin-challenges.md` — clarify that **Import with `?confirm=overwrite` replaces the entire challenge set** (not just upserts by name). Update the "Importing the same document twice is idempotent" line to state the full-replace semantics. - - `docs/architecture/key-files.md` — update the one-line responsibility for `challenges.service.ts` to mention the replace-on-confirmed-import contract. - -- **To Create:** - - `tests/backend/admin-challenges-import-replace.spec.ts` — focused contract test asserting that an overwrite-confirmed import removes pre-existing challenges (including their files and solves) and leaves only the imported set; checks both DB and the on-disk `challenges/` directory. - - `tests/frontend/admin-challenges-import-autoclose.spec.ts` — pure/smart test asserting that after a successful `onImportConfirm`, the import modal closes (i.e. `importOpen()` flips to `false`) and the result is still surfaced via the page-level status banner. - -## 3. Proposed Changes - -### 3.1 Backend — `AdminChallengesService.importConfirmed` becomes a full replace - -In `backend/src/modules/admin/challenges.service.ts`, refactor `importConfirmed` (currently lines 412–522) so the confirmed path: - -1. **Stage every file up front** (unchanged logic, lines 421–446). Keep the early `rollbackStaged` on validation failure. -2. **Inside one transaction** (unchanged transaction wrapper, line 450): - a. Load every existing challenge row with `manager.getRepository(ChallengeEntity).find()` (no filter). - b. Capture each existing challenge's `ChallengeFileEntity` rows (`find({ where: { challengeId: In(ids) } })`) so we know which `storedFilename`s are still referenced after the transaction. - c. Delete every existing challenge row with a single `manager.delete(ChallengeEntity, {})` — TypeORM cascades to `challenge_file` and `solve` rows via the existing FK cascade. This is the **replace** semantics. - d. Then iterate `doc.challenges` and (as today) skip entries whose category is unknown, otherwise create a new challenge row + new file rows pointing at the already-staged tokens. Result counts stay `{ imported, skipped, warnings }`. -3. **After commit, clean up disk** — for the captured pre-existing stored filenames, call `fileService.removeFinal(storedFilename)` best-effort (same helper used by `remove()`). Newly-inserted challenges' files are then committed via the existing `commit(item.staged.token)` loop (lines 506–517). -4. **Do not break the preview path.** `previewImport` keeps the existing conflict/unknown-category logic; the destructive replace only happens when `?confirm=overwrite` (controller path `admin-challenges.controller.ts:87-92`). -5. **No changes to DTOs or to the export shape.** `exportAll()` already produces the versioned envelope that the new behavior requires for round-tripping. - -**Edge cases to handle explicitly:** -- Empty `doc.challenges` ⇒ result `{ imported: 0, skipped: [], warnings: [] }`; existing rows are wiped, files unlinked, no new rows created. -- Unknown-category rows ⇒ counted in `skipped`; do **not** preserve them, do **not** abort the wipe. The wipe happens before the per-challenge inserts. -- Disk-unlink failure for a removed file ⇒ swallow + log via `ChallengeFilesService.removeFinal` (matches existing delete behavior, never throws). - -### 3.2 Frontend — `AdminChallengesComponent.onImportConfirm` closes the modal - -In `frontend/src/app/features/admin/challenges/challenges.component.ts`: - -- In `onImportConfirm()` (lines 455–470), after `await this.load()` succeeds and `importResult` is set, additionally call `closeImport()`. `closeImport()` already clears `importDoc`, `importPreview`, `importResult`, and `importError` (lines 447–453) and resets `importOpen` to `false`. -- The page-level status banner is already populated by `this.statusMessage.set(summarizeImportResult(result))` immediately before `closeImport()`, so the user still sees the "Imported N challenges" message after the dialog closes. -- The `` template already renders a "Close" button while `result()` is set; that branch becomes unreachable after the fix (the modal will close before the user can click it), so no template change is required. The result branch is kept as a defensive fallback for any future flow that intentionally keeps the modal open (e.g. partial-failure display). - -### 3.3 Docs — clarify the replace semantics - -- `docs/guides/admin-challenges.md` — under the "Import" section (line 103) replace the third bullet ("Click Overwrite & Import … the server upserts challenges inside one transaction by case-insensitive name") with: "Click **Overwrite & Import** → the client posts the same document with `?confirm=overwrite`. The server stages every file up front (rolling back any FS failure), then **wipes every existing challenge (cascade-deleting their `challenge_file` and `solve` rows and best-effort unlinking their disk files) and inserts the archive's challenges** in one transaction." -- Update the last "Notes" line on idempotency (line 138) to: "Importing the same document twice is idempotent: every existing challenge (matching or not) is replaced by the archive contents." -- `docs/architecture/key-files.md` — update the `challenges.service.ts` responsibility to "... CRUD, list aggregates, transactional deletion, export, full-replace import orchestration." - -## 4. Test Strategy - -### 4.1 Backend contract test — `tests/backend/admin-challenges-import-replace.spec.ts` - -- Mirror the wiring of `tests/backend/admin-challenges-import-export.spec.ts`: `:memory:` SQLite, unique `mkdtempSync(os.tmpdir() + '/hipctf-challenges-import-replace-…')` `UPLOAD_DIR`, all migrations including `UpgradeChallengeAdminSchema1700000000500`, TypeOrmModule.forFeature for `CategoryEntity`, `ChallengeEntity`, `ChallengeFileEntity`, `SolveEntity`. -- AAA structure. Mocking strategy: no external boundaries — use real in-memory SQLite + real `ChallengeFilesService` writing to the temp dir. Isolation: per-file temp dir (no parallel FS races). -- Tests (minimal, one logical assertion each): - 1. **Replace wipes unrelated challenges** — Seed `Alpha` (CRY, NC, port=1337, flag='flag{a}', with one staged file committed) and `Bravo` (CRY, WEB). Call `importConfirmed` with a single-challenge doc `{ name: 'Imported Charlie', … categorySystemKey: 'CRY', … }`. Assert `list({}).map(r => r.name)` is exactly `['Imported Charlie']` and that `chRepo.count()` equals 1. - 2. **Disk files of wiped challenges are unlinked** — Capture the stored filename of Alpha's committed file via `fileRepo.findOne({ where: { challengeId: alpha.id } })`. After the import, assert `fs.existsSync(path.join(finalDir, alphaStoredName))` is `false`, while the new challenge's stored file exists. - 3. **Empty archive wipes everything** — Re-seed Alpha, then `importConfirmed` with `{ challenges: [] }`. Assert `chRepo.count() === 0` and the previously-committed disk file is unlinked. - 4. **Unknown-category rows are skipped, not preserved** — Seed `Keep` (WEB). Import a doc containing one unknown-category challenge plus nothing else. Assert `result.skipped` has length 1 and `list({})` is empty (the wipe still happened). - -### 4.2 Frontend modal test — `tests/frontend/admin-challenges-import-autoclose.spec.ts` - -- Smart container test using `TestBed.createComponent(AdminChallengesComponent)` to exercise the live handler. -- Mock `AdminService` (`jest.fn()` for `previewChallengeImport`, `confirmChallengeImport`, `listChallenges`, `listCategories`, `exportChallenges`) and stub the document `createElement('input')` file-picker flow by setting `importDoc`/`importPreview` directly via a small helper (or by calling the public `openImport()` + injecting the file via a fake `` ref). Keep it minimal: directly drive the component signals is preferable to mocking the file picker. -- AAA structure. Mocking strategy: only `AdminService` (network boundary). No DOM snapshotting; we assert on signal values (OnPush friendly) and DOM text via `fixture.nativeElement.textContent` if needed. -- Tests: - 1. **`onImportConfirm` closes the modal after success** — arrange: `component.importOpen.set(true)`, `component.importDoc.set({ … })`, `component.importPreview.set({ total: 1, conflicts: [], unknownCategories: [], warnings: [] })`. Stub `admin.confirmChallengeImport` to resolve `{ imported: 1, skipped: [], warnings: [] }` and `admin.listChallenges` to resolve `[]`. Act: `await component.onImportConfirm()`. Assert `component.importOpen()` is `false`, `component.importResult()` is `null` (cleared by `closeImport`), and `component.statusMessage()` contains "Imported 1". - 2. **`onImportConfirm` keeps the modal open when the network call rejects** — arrange same, stub `confirmChallengeImport` to reject with a generic error. Act + assert `component.importOpen()` stays `true` and `component.importError()` is non-null. - -### 4.3 Run command - -- From the repo root: `npm test` (or `npm run test:backend` / `npm run test:frontend` for individual projects). Both new specs run inside the existing `ts-jest` configuration with no additional setup. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 798201b..33831bf 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { SettingsModule } from './modules/settings/settings.module'; import { AdminModule } from './modules/admin/admin.module'; import { UploadsModule } from './modules/uploads/uploads.module'; import { BlogModule } from './modules/blog/blog.module'; +import { ChallengesModule } from './modules/challenges/challenges.module'; import { FrontendModule } from './frontend/frontend.module'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; @@ -33,6 +34,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; AdminModule, UploadsModule, BlogModule, + ChallengesModule, FrontendModule, ], providers: [ diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index a8e0c51..4b98425 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -27,6 +27,9 @@ export const ERROR_CODES = { CHALLENGE_FILE_LIMIT: 'CHALLENGE_FILE_LIMIT', IMPORT_VALIDATION_FAILED: 'IMPORT_VALIDATION_FAILED', IMPORT_PARTIAL_FAILURE: 'IMPORT_PARTIAL_FAILURE', + EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING', + FLAG_INCORRECT: 'FLAG_INCORRECT', + CHALLENGE_DISABLED: 'CHALLENGE_DISABLED', INTERNAL: 'INTERNAL', } as const; diff --git a/backend/src/modules/challenges/challenges.controller.ts b/backend/src/modules/challenges/challenges.controller.ts new file mode 100644 index 0000000..7cf5d43 --- /dev/null +++ b/backend/src/modules/challenges/challenges.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Req, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { EventStatusService } from '../../common/services/event-status.service'; +import { ChallengesService } from './challenges.service'; +import { + BoardQuerySchema, + BoardResponseDto, + ChallengeDetailDto, + ChallengeIdParamSchema, + SolveResponseDto, + SolveSubmitBodySchema, +} from './dto/challenges.dto'; +import type { EventStatePayload } from '../../common/services/event-status.service'; + +@ApiTags('challenges') +@ApiBearerAuth() +@Controller('api/v1/challenges') +export class ChallengesController { + constructor( + private readonly svc: ChallengesService, + private readonly statusSvc: EventStatusService, + ) {} + + private extractUserId(req: Request): string { + const user = req.user as { sub: string } | undefined; + const userId = user?.sub; + if (!userId) { + throw new (require('@nestjs/common') as typeof import('@nestjs/common')).UnauthorizedException('Not authenticated'); + } + return userId; + } + + @Get('board') + @ApiOperation({ + summary: + 'Authenticated board: categories with challenges, live points, current player solved set. ?include=solvers attaches solver lists to each card.', + }) + async board( + @Req() req: Request, + @Query(new ZodValidationPipe(BoardQuerySchema)) query: { include?: string }, + ): Promise { + const userId = this.extractUserId(req); + const includeTokens = (query.include ?? '') + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + return this.svc.getBoard(userId, { includeSolvers: includeTokens.includes('solvers') }); + } + + @Get('status') + @ApiOperation({ + summary: 'Authenticated event-window snapshot (REST JSON). Source of truth for the challenges page bootstrap.', + }) + async status(): Promise { + return this.statusSvc.getState(); + } + + @Get(':id') + @ApiOperation({ summary: 'Single challenge detail with solvers list' }) + async detail( + @Req() req: Request, + @Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string }, + ): Promise { + const userId = this.extractUserId(req); + return this.svc.getDetail(userId, params.id); + } + + @Post(':id/solves') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Submit a flag for a challenge; idempotent per (player, challenge)' }) + async submit( + @Req() req: Request, + @Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string }, + @Body(new ZodValidationPipe(SolveSubmitBodySchema)) body: { flag: string }, + ): Promise { + const userId = this.extractUserId(req); + return this.svc.submitFlag(userId, params.id, body.flag); + } +} diff --git a/backend/src/modules/challenges/challenges.module.ts b/backend/src/modules/challenges/challenges.module.ts new file mode 100644 index 0000000..a4ae593 --- /dev/null +++ b/backend/src/modules/challenges/challenges.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ChallengeEntity } from '../../database/entities/challenge.entity'; +import { CategoryEntity } from '../../database/entities/category.entity'; +import { SolveEntity } from '../../database/entities/solve.entity'; +import { UserEntity } from '../../database/entities/user.entity'; +import { CommonModule } from '../../common/common.module'; +import { ChallengesController } from './challenges.controller'; +import { ChallengesEventsController } from './events.controller'; +import { ChallengesService } from './challenges.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ChallengeEntity, CategoryEntity, SolveEntity, UserEntity]), + CommonModule, + ], + controllers: [ChallengesController, ChallengesEventsController], + providers: [ChallengesService], +}) +export class ChallengesModule {} diff --git a/backend/src/modules/challenges/challenges.service.ts b/backend/src/modules/challenges/challenges.service.ts new file mode 100644 index 0000000..df75cbd --- /dev/null +++ b/backend/src/modules/challenges/challenges.service.ts @@ -0,0 +1,521 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import { ChallengeEntity } from '../../database/entities/challenge.entity'; +import { CategoryEntity } from '../../database/entities/category.entity'; +import { SolveEntity } from '../../database/entities/solve.entity'; +import { UserEntity } from '../../database/entities/user.entity'; +import { ApiError } from '../../common/errors/api-error'; +import { ERROR_CODES } from '../../common/errors/error-codes'; +import { EventStatusService } from '../../common/services/event-status.service'; +import { SseHubService } from '../../common/services/sse-hub.service'; +import { + AwardedSolveDto, + BoardResponseDto, + CategoryColumnDto, + ChallengeBoardCardDto, + ChallengeDetailDto, + Difficulty, + SolveResponseDto, + SolverRowDto, +} from './dto/challenges.dto'; +import { + compareDifficulty, + computeLivePoints, + rankBonusForPosition, + safeEqualString, +} from './scoring.util'; + +interface ChallengeRow { + id: string; + name: string; + descriptionMd: string; + categoryId: string; + difficulty: Difficulty; + initialPoints: number; + minimumPoints: number; + decaySolves: number; + enabled: boolean; + abbreviation: string; + categoryName: string; + iconPath: string; +} + +interface SolveRow { + id: string; + challengeId: string; + userId: string; + solvedAt: string; + pointsAwarded: number; + basePoints: number; + rankBonus: number; + isFirst: boolean; + isSecond: boolean; + isThird: boolean; + username: string; +} + +@Injectable() +export class ChallengesService { + private readonly logger = new Logger(ChallengesService.name); + + constructor( + @InjectRepository(ChallengeEntity) + private readonly challenges: Repository, + @InjectRepository(CategoryEntity) + private readonly categories: Repository, + @InjectRepository(SolveEntity) + private readonly solves: Repository, + private readonly dataSource: DataSource, + private readonly eventStatus: EventStatusService, + private readonly hub: SseHubService, + ) {} + + async getBoard(currentUserId: string, opts: { includeSolvers?: boolean } = {}): Promise { + const rows = await this.challenges + .createQueryBuilder('c') + .leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id') + .select('c.id', 'id') + .addSelect('c.name', 'name') + .addSelect('c.description_md', 'descriptionMd') + .addSelect('c.category_id', 'categoryId') + .addSelect('c.difficulty', 'difficulty') + .addSelect('c.initial_points', 'initialPoints') + .addSelect('c.minimum_points', 'minimumPoints') + .addSelect('c.decay_solves', 'decaySolves') + .addSelect('c.enabled', 'enabled') + .addSelect('cat.abbreviation', 'abbreviation') + .addSelect('cat.name', 'categoryName') + .addSelect('cat.icon_path', 'iconPath') + .where('c.enabled = 1') + .getRawMany(); + + if (rows.length === 0) { + return { columns: [], solvedChallengeIds: [], perChallengeLivePoints: {} }; + } + + const challengeIds = rows.map((r) => r.id); + const solveCountMap = await this.getSolveCounts(challengeIds); + const solvedSet = await this.getSolvedChallengeIds(currentUserId, challengeIds); + + const perChallengeLivePoints: Record = {}; + const byCategory = new Map(); + + for (const row of rows) { + const solveCount = solveCountMap.get(row.id) ?? 0; + const livePoints = computeLivePoints( + row.initialPoints, + row.minimumPoints, + row.decaySolves, + solveCount, + ); + perChallengeLivePoints[row.id] = livePoints; + const card: ChallengeBoardCardDto = { + id: row.id, + name: row.name, + descriptionMd: row.descriptionMd ?? '', + categoryId: row.categoryId, + categoryAbbreviation: row.abbreviation ?? '', + categoryIconPath: row.iconPath ?? '', + difficulty: row.difficulty, + livePoints, + solveCount, + solvedByMe: solvedSet.has(row.id), + }; + const existing = byCategory.get(row.categoryId); + if (existing) { + existing.cards.push(card); + } else { + byCategory.set(row.categoryId, { + cat: { + id: row.categoryId, + abbreviation: row.abbreviation ?? '', + name: row.categoryName ?? '', + iconPath: row.iconPath ?? '', + }, + cards: [card], + }); + } + } + + if (opts.includeSolvers) { + const mgr = this.dataSource.manager; + for (const col of byCategory.values()) { + for (const card of col.cards) { + const solvers = await this.loadSolvers(mgr, card.id); + card.solvers = solvers.map((s, idx) => ({ + ...this.toSolverDto(s), + position: idx + 1, + })); + } + } + } + + const columns: CategoryColumnDto[] = Array.from(byCategory.values()) + .map((entry) => ({ + id: entry.cat.id, + abbreviation: entry.cat.abbreviation, + name: entry.cat.name, + iconPath: entry.cat.iconPath, + cards: entry.cards.sort((a, b) => { + const d = compareDifficulty(a.difficulty, b.difficulty); + if (d !== 0) return d; + const an = (a.name ?? '').toLowerCase(); + const bn = (b.name ?? '').toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return 0; + }), + })) + .sort((a, b) => { + const al = (a.abbreviation ?? '').toLowerCase(); + const bl = (b.abbreviation ?? '').toLowerCase(); + if (al < bl) return -1; + if (al > bl) return 1; + return 0; + }); + + return { + columns, + solvedChallengeIds: Array.from(solvedSet), + perChallengeLivePoints, + }; + } + + async getDetail(currentUserId: string, challengeId: string): Promise { + const challenge = await this.loadChallengeOrThrow(challengeId); + const mgr = this.dataSource.manager; + const solvers = await this.loadSolvers(mgr, challengeId); + const solved = await this.solves + .createQueryBuilder('s') + .select('1') + .where('s.challenge_id = :cid AND s.user_id = :uid', { cid: challengeId, uid: currentUserId }) + .getRawOne(); + const card = this.buildCard(challenge, solvers.length, !!solved); + return { + challenge: card, + solvers: solvers.map((s, idx) => ({ + ...this.toSolverDto(s), + position: idx + 1, + })), + }; + } + + private async loadChallengeOrThrow(challengeId: string): Promise { + const row = await this.challenges + .createQueryBuilder('c') + .leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id') + .select('c.id', 'id') + .addSelect('c.name', 'name') + .addSelect('c.description_md', 'descriptionMd') + .addSelect('c.category_id', 'categoryId') + .addSelect('c.difficulty', 'difficulty') + .addSelect('c.initial_points', 'initialPoints') + .addSelect('c.minimum_points', 'minimumPoints') + .addSelect('c.decay_solves', 'decaySolves') + .addSelect('c.enabled', 'enabled') + .addSelect('cat.abbreviation', 'abbreviation') + .addSelect('cat.name', 'categoryName') + .addSelect('cat.icon_path', 'iconPath') + .where('c.id = :id', { id: challengeId }) + .getRawOne(); + if (!row || !row.enabled) throw ApiError.notFound('Challenge not found'); + return row; + } + + async submitFlag(currentUserId: string, challengeId: string, submittedFlag: string): Promise { + return this.dataSource.transaction(async (manager) => { + const challengeRepo = manager.getRepository(ChallengeEntity); + const solveRepo = manager.getRepository(SolveEntity); + const categoryRepo = manager.getRepository(CategoryEntity); + + const challenge = await challengeRepo + .createQueryBuilder('c') + .leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id') + .select('c.id', 'id') + .addSelect('c.name', 'name') + .addSelect('c.description_md', 'descriptionMd') + .addSelect('c.category_id', 'categoryId') + .addSelect('c.difficulty', 'difficulty') + .addSelect('c.initial_points', 'initialPoints') + .addSelect('c.minimum_points', 'minimumPoints') + .addSelect('c.decay_solves', 'decaySolves') + .addSelect('c.flag', 'flag') + .addSelect('c.enabled', 'enabled') + .addSelect('cat.abbreviation', 'abbreviation') + .addSelect('cat.name', 'categoryName') + .addSelect('cat.icon_path', 'iconPath') + .where('c.id = :id', { id: challengeId }) + .getRawOne(); + + if (!challenge || !challenge.enabled) { + throw ApiError.notFound('Challenge not found'); + } + + const state = await this.eventStatus.getState(); + if (state.state !== 'running') { + throw new ApiError( + ERROR_CODES.EVENT_NOT_RUNNING, + 'Submissions are only accepted while the event is running', + 409, + ); + } + + const existing = await solveRepo + .createQueryBuilder('s') + .leftJoin(UserEntity, 'u', 'u.id = s.user_id') + .select('s.id', 'id') + .addSelect('s.challenge_id', 'challengeId') + .addSelect('s.user_id', 'userId') + .addSelect('s.solved_at', 'solvedAt') + .addSelect('s.points_awarded', 'pointsAwarded') + .addSelect('s.base_points', 'basePoints') + .addSelect('s.rank_bonus', 'rankBonus') + .addSelect('s.is_first', 'isFirst') + .addSelect('s.is_second', 'isSecond') + .addSelect('s.is_third', 'isThird') + .addSelect('u.username', 'username') + .where('s.challenge_id = :cid AND s.user_id = :uid', { cid: challengeId, uid: currentUserId }) + .getRawOne(); + + if (existing) { + const solvers = await this.loadSolvers(manager, challengeId); + const count = solvers.length; + const card = this.buildCard(challenge, count, true); + const myIndex = solvers.findIndex((s) => s.userId === currentUserId); + const awarded: AwardedSolveDto = { + position: myIndex >= 0 ? myIndex + 1 : 0, + basePoints: existing.basePoints, + rankBonus: existing.rankBonus, + awardedPoints: existing.pointsAwarded, + awardedAtUtc: existing.solvedAt, + }; + return { + status: 'already_solved', + challenge: card, + awarded, + solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })), + }; + } + + if (!safeEqualString(challenge.flag ?? '', submittedFlag ?? '')) { + throw new ApiError( + ERROR_CODES.FLAG_INCORRECT, + 'Incorrect flag', + 400, + ); + } + + const positionRow = await solveRepo + .createQueryBuilder('s') + .select('COUNT(*)', 'count') + .where('s.challenge_id = :cid', { cid: challengeId }) + .getRawOne<{ count: string | number }>(); + const position = Number(positionRow?.count ?? 0) + 1; + + const solveCountBefore = position - 1; + const basePoints = computeLivePoints( + challenge.initialPoints, + challenge.minimumPoints, + challenge.decaySolves, + solveCountBefore, + ); + const rankBonus = rankBonusForPosition(position); + const awardedPoints = basePoints + rankBonus; + const awardedAtUtc = new Date().toISOString(); + + try { + await solveRepo.insert({ + id: uuid(), + challengeId, + userId: currentUserId, + solvedAt: awardedAtUtc, + pointsAwarded: awardedPoints, + basePoints, + rankBonus, + isFirst: position === 1, + isSecond: position === 2, + isThird: position === 3, + }); + } catch (err: any) { + const code = err && typeof err === 'object' ? (err.code as string) : null; + if (code === 'SQLITE_CONSTRAINT_UNIQUE' || code === 'SQLITE_CONSTRAINT') { + const solvers = await this.loadSolvers(manager, challengeId); + const card = this.buildCard(challenge, solvers.length, true); + const mine = solvers.find((s) => s.userId === currentUserId); + const myIdx = mine ? solvers.indexOf(mine) : -1; + return { + status: 'already_solved', + challenge: card, + awarded: { + position: myIdx >= 0 ? myIdx + 1 : position, + basePoints: mine?.basePoints ?? basePoints, + rankBonus: mine?.rankBonus ?? rankBonus, + awardedPoints: mine?.pointsAwarded ?? awardedPoints, + awardedAtUtc: mine?.solvedAt ?? awardedAtUtc, + }, + solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })), + }; + } + throw err; + } + + const solvers = await this.loadSolvers(manager, challengeId); + const card = this.buildCard(challenge, solvers.length, true); + const awarded: AwardedSolveDto = { + position, + basePoints, + rankBonus, + awardedPoints, + awardedAtUtc, + }; + + this.publishSolveEvent({ + challengeId, + userId: currentUserId, + username: solvers.find((s) => s.userId === currentUserId)?.username ?? '', + awardedPoints, + rankBonus, + position, + solveCountAfter: solvers.length, + livePointsAfter: computeLivePoints( + challenge.initialPoints, + challenge.minimumPoints, + challenge.decaySolves, + solvers.length, + ), + initialPoints: challenge.initialPoints, + minimumPoints: challenge.minimumPoints, + decaySolves: challenge.decaySolves, + solvedAtUtc: awardedAtUtc, + manager, + }); + + void categoryRepo; + + return { + status: 'solved', + challenge: card, + awarded, + solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })), + }; + }); + } + + private publishSolveEvent(input: { + challengeId: string; + userId: string; + username: string; + awardedPoints: number; + rankBonus: number; + position: number; + solveCountAfter: number; + livePointsAfter: number; + initialPoints: number; + minimumPoints: number; + decaySolves: number; + solvedAtUtc: string; + manager: any; + }): void { + try { + this.hub.emitScoreboard({ + topic: 'solve', + challengeId: input.challengeId, + userId: input.userId, + playerId: input.userId, + playerName: input.username, + awardedPoints: input.awardedPoints, + rankBonus: input.rankBonus, + pointsAwarded: input.awardedPoints, + solvedAt: input.solvedAtUtc, + awardedAtUtc: input.solvedAtUtc, + position: input.position, + livePoints: input.livePointsAfter, + solveCount: input.solveCountAfter, + initialPoints: input.initialPoints, + minimumPoints: input.minimumPoints, + decaySolves: input.decaySolves, + }); + } catch (err) { + this.logger.warn(`Failed to publish solve SSE: ${(err as Error).message}`); + } + } + + private async getSolveCounts(challengeIds: string[]): Promise> { + if (challengeIds.length === 0) return new Map(); + const rows = await this.solves + .createQueryBuilder('s') + .select('s.challenge_id', 'challengeId') + .addSelect('COUNT(*)', 'count') + .where('s.challenge_id IN (:...ids)', { ids: challengeIds }) + .groupBy('s.challenge_id') + .getRawMany<{ challengeId: string; count: string | number }>(); + const map = new Map(); + for (const r of rows) map.set(r.challengeId, Number(r.count)); + return map; + } + + private async getSolvedChallengeIds(userId: string, challengeIds: string[]): Promise> { + if (challengeIds.length === 0) return new Set(); + const rows = await this.solves + .createQueryBuilder('s') + .select('s.challenge_id', 'challengeId') + .where('s.user_id = :uid AND s.challenge_id IN (:...ids)', { uid: userId, ids: challengeIds }) + .getRawMany<{ challengeId: string }>(); + return new Set(rows.map((r) => r.challengeId)); + } + + private async loadSolvers(manager: any, challengeId: string): Promise { + const qb = manager + .getRepository(SolveEntity) + .createQueryBuilder('s') + .leftJoin(UserEntity, 'u', 'u.id = s.user_id') + .select('s.id', 'id') + .addSelect('s.challenge_id', 'challengeId') + .addSelect('s.user_id', 'userId') + .addSelect('s.solved_at', 'solvedAt') + .addSelect('s.points_awarded', 'pointsAwarded') + .addSelect('s.base_points', 'basePoints') + .addSelect('s.rank_bonus', 'rankBonus') + .addSelect('s.is_first', 'isFirst') + .addSelect('s.is_second', 'isSecond') + .addSelect('s.is_third', 'isThird') + .addSelect('u.username', 'username') + .where('s.challenge_id = :cid', { cid: challengeId }) + .orderBy('s.solved_at', 'ASC') + .addOrderBy('s.id', 'ASC'); + return (await qb.getRawMany()) as SolveRow[]; + } + + private toSolverDto(s: SolveRow): SolverRowDto { + return { + position: 0, // filled by caller + playerId: s.userId, + playerName: s.username ?? '', + solvedAtUtc: s.solvedAt, + awardedPoints: s.pointsAwarded, + basePoints: s.basePoints, + rankBonus: s.rankBonus, + isFirst: Boolean(s.isFirst), + isSecond: Boolean(s.isSecond), + isThird: Boolean(s.isThird), + }; + } + + private buildCard(row: ChallengeRow, solveCount: number, solvedByMe: boolean): ChallengeBoardCardDto { + const livePoints = computeLivePoints(row.initialPoints, row.minimumPoints, row.decaySolves, solveCount); + return { + id: row.id, + name: row.name, + descriptionMd: row.descriptionMd ?? '', + categoryId: row.categoryId, + categoryAbbreviation: row.abbreviation ?? '', + categoryIconPath: row.iconPath ?? '', + difficulty: row.difficulty, + livePoints, + solveCount, + solvedByMe, + }; + } +} diff --git a/backend/src/modules/challenges/dto/challenges.dto.ts b/backend/src/modules/challenges/dto/challenges.dto.ts new file mode 100644 index 0000000..100e088 --- /dev/null +++ b/backend/src/modules/challenges/dto/challenges.dto.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; + +export const DIFFICULTIES = ['LOW', 'MEDIUM', 'HIGH'] as const; +export type Difficulty = (typeof DIFFICULTIES)[number]; + +export const SolveSubmitBodySchema = z.object({ + flag: z.string().min(1).max(4096), +}); +export type SolveSubmitBody = z.infer; + +export const BoardQuerySchema = z.object({ + include: z.string().optional(), +}); +export type BoardQuery = z.infer; + +export const ChallengeIdParamSchema = z.object({ + id: z.string().uuid({ message: 'Invalid challenge id' }), +}); +export type ChallengeIdParam = z.infer; + +export interface ChallengeBoardCardDto { + id: string; + name: string; + descriptionMd: string; + categoryId: string; + categoryAbbreviation: string; + categoryIconPath: string; + difficulty: Difficulty; + livePoints: number; + solveCount: number; + solvedByMe: boolean; + solvers?: SolverRowDto[]; +} + +export interface CategoryColumnDto { + id: string; + abbreviation: string; + name: string; + iconPath: string; + cards: ChallengeBoardCardDto[]; +} + +export interface BoardResponseDto { + columns: CategoryColumnDto[]; + solvedChallengeIds: string[]; + perChallengeLivePoints: Record; +} + +export interface SolverRowDto { + position: number; + playerId: string; + playerName: string; + solvedAtUtc: string; + awardedPoints: number; + basePoints: number; + rankBonus: number; + isFirst: boolean; + isSecond: boolean; + isThird: boolean; +} + +export interface AwardedSolveDto { + position: number; + basePoints: number; + rankBonus: number; + awardedPoints: number; + awardedAtUtc: string; +} + +export interface SolveResponseDto { + status: 'solved' | 'already_solved'; + challenge: ChallengeBoardCardDto; + awarded: AwardedSolveDto; + solvers: SolverRowDto[]; +} + +export interface ChallengeDetailDto { + challenge: ChallengeBoardCardDto; + solvers: SolverRowDto[]; +} + +export interface SolveEventPayload { + challengeId: string; + playerId: string; + playerName: string; + awardedPoints: number; + rankBonus: number; + awardedAtUtc: string; + position: number; + livePoints: number; + solveCount: number; + initialPoints: number; + minimumPoints: number; + decaySolves: number; +} diff --git a/backend/src/modules/challenges/events.controller.ts b/backend/src/modules/challenges/events.controller.ts new file mode 100644 index 0000000..7ab1875 --- /dev/null +++ b/backend/src/modules/challenges/events.controller.ts @@ -0,0 +1,103 @@ +import { Controller, MessageEvent, Sse } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + Observable, + defer, + interval, + merge, + startWith, + mergeMap, + filter, + map, + distinctUntilChanged, +} from 'rxjs'; +import { EventStatusService } from '../../common/services/event-status.service'; +import { SseHubService } from '../../common/services/sse-hub.service'; + +@ApiTags('challenges') +@ApiBearerAuth() +@Controller('api/v1') +export class ChallengesEventsController { + constructor( + private readonly statusSvc: EventStatusService, + private readonly hub: SseHubService, + ) {} + + /** + * Authenticated global event stream. + * Emits NestJS MessageEvent objects with the proper `type` field so the + * wire format is `event: \ndata: \n\n`: + * - event: status, payload = { state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end } + * - event: solve, payload = { challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, + * position, live_points, solve_count, initial_points, minimum_points, decay_solves } + * Source of truth: every transition of the event window is broadcast by + * the admin general service through SseHubService.event$(). Solves are + * published by ChallengesService through SseHubService.scoreboard$(). + * To recover missed transitions while disconnected, the status tick + * re-pulls getState() every 60 seconds. + */ + @Sse('events') + @ApiOperation({ + summary: 'Authenticated global SSE stream: status + solve events', + }) + events(): Observable { + const flatStatus = (s: any): MessageEvent => ({ + type: 'status', + data: { + state: s?.state, + server_time_utc: s?.serverNowUtc, + event_start_utc: s?.eventStartUtc ?? null, + event_end_utc: s?.eventEndUtc ?? null, + seconds_to_start: s?.secondsToStart ?? null, + seconds_to_end: s?.secondsToEnd ?? null, + }, + }); + + const statusTick$ = interval(60_000).pipe( + startWith(0), + mergeMap(() => + defer(() => this.statusSvc.getState().then((s) => flatStatus(s))), + ), + ); + + const statusHub$ = this.hub.event$().pipe( + map((p) => { + if (p && p.topic === 'general') { + return defer(() => this.statusSvc.getState().then((s) => flatStatus(s))); + } + if (p && (p.state || p.eventStartUtc || p.eventEndUtc)) { + return defer(async () => flatStatus(p)); + } + return null; + }), + filter((x): x is Observable => !!x), + mergeMap((x) => x), + ); + + const solveFrame = (s: any): MessageEvent => ({ + type: 'solve', + data: { + challenge_id: s?.challengeId, + player_id: s?.playerId ?? s?.userId, + player_name: s?.playerName ?? '', + awarded_points: s?.awardedPoints ?? s?.pointsAwarded, + rank_bonus: s?.rankBonus, + awarded_at_utc: s?.awardedAtUtc ?? s?.solvedAt, + position: s?.position, + live_points: s?.livePoints, + solve_count: s?.solveCount, + initial_points: s?.initialPoints, + minimum_points: s?.minimumPoints, + decay_solves: s?.decaySolves, + }, + }); + + const solve$ = this.hub.scoreboard$().pipe(map((s) => solveFrame(s))); + + const statusFrames$ = merge(statusTick$, statusHub$).pipe( + distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), + ); + + return merge(statusFrames$, solve$); + } +} diff --git a/backend/src/modules/challenges/scoring.util.ts b/backend/src/modules/challenges/scoring.util.ts new file mode 100644 index 0000000..c8f4abb --- /dev/null +++ b/backend/src/modules/challenges/scoring.util.ts @@ -0,0 +1,54 @@ +import { timingSafeEqual } from 'crypto'; + +export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH'; + +export const DIFFICULTY_RANK: Readonly> = { + LOW: 0, + MEDIUM: 1, + HIGH: 2, +}; + +export function compareDifficulty(a: Difficulty, b: Difficulty): number { + return DIFFICULTY_RANK[a] - DIFFICULTY_RANK[b]; +} + +export function computeLivePoints( + initial: number, + minimum: number, + decaySolves: number, + currentSolveCount: number, +): number { + if (!Number.isFinite(initial) || !Number.isFinite(minimum)) return minimum; + const i = Math.max(0, Math.floor(initial)); + const m = Math.max(0, Math.floor(minimum)); + const d = Math.max(0, Math.floor(decaySolves)); + const c = Math.max(0, Math.floor(currentSolveCount)); + if (i < m) return Math.max(0, m); + if (d <= 0) return i; + if (c >= d) return m; + const span = i - m; + const raw = i - span * (c / d); + return Math.max(m, Math.round(raw)); +} + +export function rankBonusForPosition(position: number): number { + if (!Number.isInteger(position) || position <= 0) return 0; + if (position === 1) return 15; + if (position === 2) return 10; + if (position === 3) return 5; + return 0; +} + +export function safeEqualString(a: string, b: string): boolean { + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) { + let diff = ab.length ^ bb.length; + const max = Math.max(ab.length, bb.length); + for (let i = 0; i < max; i += 1) { + diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); + } + return false; + } + return timingSafeEqual(ab, bb); +} diff --git a/frontend/src/app/core/interceptors/error-notification.interceptor.ts b/frontend/src/app/core/interceptors/error-notification.interceptor.ts new file mode 100644 index 0000000..bd80176 --- /dev/null +++ b/frontend/src/app/core/interceptors/error-notification.interceptor.ts @@ -0,0 +1,52 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, throwError } from 'rxjs'; +import { NotificationService } from '../services/notification.service'; + +const FRIENDLY_MESSAGES: Record = { + EVENT_NOT_RUNNING: 'Submissions are only accepted while the event is running.', + FLAG_INCORRECT: 'Incorrect flag.', + UNAUTHORIZED: 'Your session is no longer valid.', + FORBIDDEN: 'You are not allowed to perform this action.', + NOT_FOUND: 'Not found.', + VALIDATION_FAILED: 'Invalid input.', +}; + +function friendlyMessage(status: number, body: any): string { + if (status === 0) return 'Network error. Please try again.'; + const code = body?.code as string | undefined; + if (code && FRIENDLY_MESSAGES[code]) return FRIENDLY_MESSAGES[code]; + if (status === 401 || status === 403) return FRIENDLY_MESSAGES.UNAUTHORIZED; + if (status === 404) return FRIENDLY_MESSAGES.NOT_FOUND; + if (status >= 500) return 'Server error. Please try again later.'; + const msg = (body?.message as string | undefined) ?? ''; + return msg || `Request failed (${status}).`; +} + +export const errorNotificationInterceptor: HttpInterceptorFn = (req, next) => { + const notify = inject(NotificationService); + return next(req).pipe( + catchError((err) => { + if (err instanceof HttpErrorResponse) { + const url = err.url ?? req.url; + // Suppress duplicate notifications for endpoints that have their own + // user-facing error presentation (the modal shows its own banner). + if (!shouldSuppress(url, err.status)) { + notify.error(friendlyMessage(err.status, err.error)); + } + } else { + notify.error('Unexpected error.'); + } + return throwError(() => err); + }), + ); +}; + +function shouldSuppress(url: string | undefined, status: number): boolean { + if (!url) return false; + // 401/403 from a /api/v1/auth/* call is expected (login failed). Don't toast it. + if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true; + // SSE / event-status snapshot errors are non-fatal (SSE will deliver the next frame). + if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true; + return false; +} diff --git a/frontend/src/app/core/services/authenticated-event-source.service.ts b/frontend/src/app/core/services/authenticated-event-source.service.ts index ec0a726..741d073 100644 --- a/frontend/src/app/core/services/authenticated-event-source.service.ts +++ b/frontend/src/app/core/services/authenticated-event-source.service.ts @@ -39,7 +39,12 @@ export class AuthenticatedEventSourceService { buffered = []; for (const m of items) { const me = new MessageEvent(m.event, { data: m.data }); + // Dispatch to 'message' listeners first so existing consumers + // that only listen for 'message' still receive every frame. dispatch('message', me); + if (m.event && m.event !== 'message') { + dispatch(m.event, me); + } } }; @@ -99,8 +104,12 @@ export class AuthenticatedEventSourceService { } if (dataLines.length === 0) return; const payload = dataLines.join('\n'); - if (listeners.has('message')) { - dispatch('message', new MessageEvent(event, { data: payload })); + if (listeners.size > 0) { + const me = new MessageEvent(event, { data: payload }); + dispatch('message', me); + if (event && event !== 'message' && listeners.has(event)) { + dispatch(event, me); + } } else { buffered.push({ event, data: payload }); } diff --git a/frontend/src/app/core/services/event-status.pure.ts b/frontend/src/app/core/services/event-status.pure.ts index 65dbbcb..305c5e9 100644 --- a/frontend/src/app/core/services/event-status.pure.ts +++ b/frontend/src/app/core/services/event-status.pure.ts @@ -11,7 +11,7 @@ export interface EventStatePayload { export interface EventSourceLike { addEventListener( - type: 'open' | 'message' | 'error' | 'unauthorized', + type: 'open' | 'message' | 'error' | 'unauthorized' | string, listener: (ev: MessageEvent | Event) => void, ): void; close(): void; diff --git a/frontend/src/app/core/services/event-status.store.ts b/frontend/src/app/core/services/event-status.store.ts index 2a077b8..e2cc6c5 100644 --- a/frontend/src/app/core/services/event-status.store.ts +++ b/frontend/src/app/core/services/event-status.store.ts @@ -26,14 +26,28 @@ export class EventStatusStore { readonly serverNowUtc = signal(null); readonly eventStartUtc = signal(null); readonly eventEndUtc = signal(null); - private readonly anchorDeltaMs = signal(0); + private readonly clientAnchorMs = signal(0); private readonly tick = signal(0); + /** + * Anchor-based clock: serverTimeUtc received at clientAnchorMs wall-clock. + * `currentServerTimeUtc` advances from the server snapshot at the rate + * the local clock advances since we received it. + */ + readonly currentServerTimeUtc = computed(() => { + void this.tick(); + const base = this.serverNowUtc(); + if (!base) return new Date().toISOString(); + const baseMs = new Date(base).getTime(); + const ms = baseMs + (Date.now() - this.clientAnchorMs()); + return new Date(ms).toISOString(); + }); + private readonly secondsToStart = computed(() => { void this.tick(); const s = this.eventStartUtc(); if (!s) return null; - const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs(); + const anchor = new Date(this.currentServerTimeUtc()).getTime(); const diff = Math.floor((new Date(s).getTime() - anchor) / 1000); return diff > 0 ? diff : 0; }); @@ -42,7 +56,7 @@ export class EventStatusStore { void this.tick(); const s = this.eventEndUtc(); if (!s) return null; - const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs(); + const anchor = new Date(this.currentServerTimeUtc()).getTime(); const diff = Math.floor((new Date(s).getTime() - anchor) / 1000); return diff > 0 ? diff : 0; }); @@ -51,8 +65,14 @@ export class EventStatusStore { return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd()); }); + readonly secondsToStartPublic = computed(() => this.secondsToStart()); + readonly secondsToEndPublic = computed(() => this.secondsToEnd()); + private intervalId: ReturnType | null = null; private source: EventSourceLike | null = null; + private zeroWatcher: ReturnType | null = null; + private reloadOnZero: (() => void) | null = null; + private reloadOnZeroFired = false; constructor() { this.destroyRef.onDestroy(() => this.stop()); @@ -63,14 +83,18 @@ export class EventStatusStore { this.serverNowUtc.set(payload.serverNowUtc); this.eventStartUtc.set(payload.eventStartUtc); this.eventEndUtc.set(payload.eventEndUtc); - this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime()); + this.clientAnchorMs.set(Date.now()); + // Reset the one-shot so a subsequent transition (e.g. countdown→running) can fire again. + if (this.reloadOnZeroFired) { + this.reloadOnZeroFired = false; + } } start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void { this.stop(); const src = createSource(); this.source = src; - src.addEventListener('message', (ev) => { + const handler = (ev: MessageEvent | Event) => { const me = ev as MessageEvent; try { const data = typeof me.data === 'string' ? JSON.parse(me.data) : me.data; @@ -80,7 +104,9 @@ export class EventStatusStore { } catch { // ignore malformed frame } - }); + }; + src.addEventListener('message', handler); + src.addEventListener('status', handler); if (onUnauthorized) { src.addEventListener('unauthorized', () => { try { @@ -93,6 +119,32 @@ export class EventStatusStore { if (!this.intervalId) { this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000); } + if (!this.zeroWatcher) { + this.zeroWatcher = setInterval(() => this.checkZero(), 1000); + } + } + + reloadAtCountdownZero(handler: () => void): void { + this.reloadOnZero = handler; + this.reloadOnZeroFired = false; + } + + private checkZero(): void { + if (!this.reloadOnZero || this.reloadOnZeroFired) return; + const state = this.state(); + if (state === 'countdown') { + const s = this.secondsToStart(); + if (s !== null && s <= 0) { + this.reloadOnZeroFired = true; + try { this.reloadOnZero(); } catch { /* ignore */ } + } + } else if (state === 'running') { + const s = this.secondsToEnd(); + if (s !== null && s <= 0) { + this.reloadOnZeroFired = true; + try { this.reloadOnZero(); } catch { /* ignore */ } + } + } } stop(): void { @@ -108,5 +160,9 @@ export class EventStatusStore { clearInterval(this.intervalId); this.intervalId = null; } + if (this.zeroWatcher) { + clearInterval(this.zeroWatcher); + this.zeroWatcher = null; + } } } diff --git a/frontend/src/app/core/services/notification.service.ts b/frontend/src/app/core/services/notification.service.ts new file mode 100644 index 0000000..d4b85d0 --- /dev/null +++ b/frontend/src/app/core/services/notification.service.ts @@ -0,0 +1,47 @@ +import { Injectable, signal } from '@angular/core'; + +export type NotificationKind = 'error' | 'info'; + +export interface NotificationMessage { + id: number; + kind: NotificationKind; + message: string; + ts: number; +} + +@Injectable({ providedIn: 'root' }) +export class NotificationService { + private nextId = 1; + readonly messages = signal([]); + + error(message: string): void { + const note = this.push('error', message); + // eslint-disable-next-line no-console + console.error('[notification:error]', note.message); + } + + info(message: string): void { + const note = this.push('info', message); + // eslint-disable-next-line no-console + console.info('[notification:info]', note.message); + } + + dismiss(id: number): void { + this.messages.update((cur) => cur.filter((m) => m.id !== id)); + } + + clear(): void { + this.messages.set([]); + } + + private push(kind: NotificationKind, message: string): NotificationMessage { + const note: NotificationMessage = { + id: this.nextId++, + kind, + message, + ts: Date.now(), + }; + this.messages.update((cur) => [...cur, note]); + return note; + } +} diff --git a/frontend/src/app/features/challenges/category-column.component.ts b/frontend/src/app/features/challenges/category-column.component.ts new file mode 100644 index 0000000..c23aaa5 --- /dev/null +++ b/frontend/src/app/features/challenges/category-column.component.ts @@ -0,0 +1,50 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CategoryColumn } from './challenges.pure'; +import { ChallengeCardComponent } from './challenge-card.component'; + +@Component({ + selector: 'app-category-column', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, ChallengeCardComponent], + styles: [` + :host { display: block; width: 280px; flex: 0 0 280px; } + .col { + display: flex; flex-direction: column; gap: 12px; + padding: 12px; background: var(--color-surface-2, rgba(255,255,255,0.02)); + border: 1px solid var(--color-border, #2a2f3a); border-radius: var(--radius-md, 8px); + min-height: 200px; + } + .head { display: flex; align-items: center; gap: 8px; } + .icon { width: 32px; height: 32px; object-fit: contain; } + .abbr { font-weight: 700; letter-spacing: 0.06em; } + .name { font-size: 12px; opacity: 0.7; } + .cards { display: flex; flex-direction: column; gap: 8px; } + `], + template: ` +
+
+ +
+
{{ column.abbreviation }}
+
{{ column.name }}
+
+
+
+ +

No challenges yet.

+
+
+ `, +}) +export class CategoryColumnComponent { + @Input({ required: true }) column!: CategoryColumn; + @Output() openCard = new EventEmitter(); + + trackByCard = (_idx: number, c: { id: string }) => c.id; +} diff --git a/frontend/src/app/features/challenges/challenge-card.component.ts b/frontend/src/app/features/challenges/challenge-card.component.ts new file mode 100644 index 0000000..9ab969c --- /dev/null +++ b/frontend/src/app/features/challenges/challenge-card.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BoardCard } from './challenges.pure'; + +@Component({ + selector: 'app-challenge-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + styles: [` + :host { display: block; } + .card { + display: flex; flex-direction: column; gap: 6px; + padding: 10px 12px; border: 1px solid var(--color-border, #2a2f3a); + border-radius: var(--radius-sm, 4px); background: var(--color-surface, #1c1f26); + cursor: pointer; transition: transform 0.1s ease; + min-height: 96px; + } + .card:hover { transform: translateY(-1px); border-color: var(--color-primary, #3b82f6); } + .card.solved { border-color: var(--color-success, #22c55e); } + .card-head { display: flex; align-items: center; gap: 8px; } + .icon { width: 28px; height: 28px; object-fit: contain; } + .name { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .check { color: var(--color-success, #22c55e); font-size: 18px; } + .meta { display: flex; gap: 8px; font-size: 12px; opacity: 0.85; align-items: center; } + .points { font-weight: 600; } + .diff { + display: inline-block; padding: 1px 6px; border-radius: 999px; font-size: 10px; letter-spacing: 0.04em; + } + .diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; } + .diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; } + .diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; } + `], + template: ` +
+
+ + {{ card.name }} + +
+
+ {{ card.difficulty }} + {{ card.livePoints }} pts + · + {{ card.solveCount }} solves +
+
+ `, +}) +export class ChallengeCardComponent { + @Input({ required: true }) card!: BoardCard; + @Output() open = new EventEmitter(); +} diff --git a/frontend/src/app/features/challenges/challenge-modal.component.ts b/frontend/src/app/features/challenges/challenge-modal.component.ts new file mode 100644 index 0000000..ec09b6b --- /dev/null +++ b/frontend/src/app/features/challenges/challenge-modal.component.ts @@ -0,0 +1,221 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + HostListener, + Input, + OnChanges, + Output, + SimpleChanges, + computed, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MarkdownService } from '../../core/services/markdown.service'; +import { ChallengesStore } from './challenges.store'; +import { + BoardCard, + SolveEventLivePayload, + SolverRow, + formatUtcToLocal, + messageForSolveError, + parseSolveError, +} from './challenges.pure'; +import { ApiErrorEnvelope } from './challenges.service'; + +@Component({ + selector: 'app-challenge-modal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FormsModule], + styles: [` + .backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + display: flex; align-items: center; justify-content: center; z-index: 1000; + } + .modal { + width: min(720px, 92vw); max-height: 90vh; overflow: auto; + background: var(--color-surface, #1c1f26); color: var(--color-text, #f4f4f5); + border-radius: var(--radius-md, 8px); + padding: 16px; display: flex; flex-direction: column; gap: 12px; + } + .header { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .header h3 { margin: 0; flex: 1; } + .pill { padding: 2px 8px; border-radius: 999px; font-size: 12px; background: rgba(255,255,255,0.06); } + .pill.diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; } + .pill.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; } + .pill.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; } + .body { white-space: normal; line-height: 1.45; } + .form { display: flex; gap: 8px; align-items: center; } + .form input { flex: 1; padding: 8px 10px; border-radius: var(--radius-sm, 4px); border: 1px solid var(--color-border, #2a2f3a); background: var(--color-input, rgba(255,255,255,0.04)); color: inherit; } + .actions { display: flex; gap: 8px; justify-content: flex-end; } + .error { color: var(--color-danger, #ef4444); font-size: 14px; } + .notice { color: var(--color-warning, #eab308); font-size: 14px; } + .solvers { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; } + .solvers li { display: grid; grid-template-columns: 28px 1fr auto auto; gap: 8px; align-items: center; padding: 4px 8px; border-radius: var(--radius-sm, 4px); background: rgba(255,255,255,0.03); } + .rank-1 { color: #fbbf24; font-weight: 700; } + .rank-2 { color: #d4d4d8; font-weight: 700; } + .rank-3 { color: #b45309; font-weight: 700; } + .rank-other { color: var(--color-success, #22c55e); } + .points { font-variant-numeric: tabular-nums; } + .close { background: transparent; border: 1px solid var(--color-border, #2a2f3a); color: inherit; padding: 4px 10px; border-radius: var(--radius-sm, 4px); cursor: pointer; } + .solved-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(34,197,94,0.12); color: #16a34a; } + .awarded-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(59,130,246,0.12); color: #60a5fa; } + `], + template: ` +
+ +
+ `, +}) +export class ChallengeModalComponent implements OnChanges { + private readonly markdown = inject(MarkdownService); + private readonly store = inject(ChallengesStore); + + @Input({ required: true }) card: BoardCard | null = null; + @Input() initialSolvers: SolverRow[] = []; + @Input() canSubmit: boolean = false; + @Input() noticeOverride: string | null = null; + + @Output() close = new EventEmitter(); + @Output() submitted = new EventEmitter<{ flag: string }>(); + + private _flagValue = signal(''); + get flagValue(): string { + return this._flagValue(); + } + set flagValue(v: string) { + this._flagValue.set(v ?? ''); + } + readonly submitting = signal(false); + readonly errorMessage = signal(null); + readonly awarded = signal<{ basePoints: number; rankBonus: number; awardedPoints: number; awardedAtUtc: string } | null>(null); + readonly notice = signal(null); + + private _solvers = signal([]); + readonly solvers = this._solvers.asReadonly(); + + readonly renderedDescription = computed(() => { + return this.markdown.render(this.card?.descriptionMd ?? ''); + }); + + ngOnChanges(changes: SimpleChanges): void { + if (changes['card']) { + this._flagValue.set(''); + this.submitting.set(false); + this.errorMessage.set(null); + this.awarded.set(null); + this.notice.set(this.noticeOverride ?? null); + this._solvers.set([...(this.initialSolvers ?? [])]); + } + if (changes['noticeOverride']) { + this.notice.set(this.noticeOverride ?? null); + } + } + + onBackdrop(ev: MouseEvent): void { + if (ev.target === ev.currentTarget) this.close.emit(); + } + + @HostListener('document:keydown.escape') + onEscape(): void { + if (!this.submitting()) this.close.emit(); + } + + async onSubmit(): Promise { + if (!this.card || this.submitting() || !this.canSubmit) return; + const flag = this._flagValue().trim(); + if (!flag) return; + this.submitting.set(true); + this.errorMessage.set(null); + try { + const resp = await this.store.submit(this.card.id, flag); + this._flagValue.set(''); + this.awarded.set(resp.awarded); + this._solvers.set(this.store.buildSolversFromResponse(resp.solvers)); + this.submitted.emit({ flag }); + if (resp.status === 'already_solved') { + this.errorMessage.set(messageForSolveError('already_solved')); + } + } catch (err) { + const envelope = err as ApiErrorEnvelope; + const code = parseSolveError(envelope.status, envelope); + this.errorMessage.set(messageForSolveError(code)); + } finally { + this.submitting.set(false); + } + } + + applyLiveSolve(payload: SolveEventLivePayload): void { + this._solvers.set(this.store.mergeLiveSolver(this._solvers(), payload)); + } + + formatLocal(utc: string): string { + return formatUtcToLocal(utc); + } + + rankClass(position: number): string { + if (position === 1) return 'rank-1'; + if (position === 2) return 'rank-2'; + if (position === 3) return 'rank-3'; + return 'rank-other'; + } + + trackBySolver(_idx: number, s: SolverRow): string { + return `${s.playerId}-${s.solvedAtUtc}`; + } +} diff --git a/frontend/src/app/features/challenges/challenges.page.ts b/frontend/src/app/features/challenges/challenges.page.ts index 23b2550..3b214fd 100644 --- a/frontend/src/app/features/challenges/challenges.page.ts +++ b/frontend/src/app/features/challenges/challenges.page.ts @@ -1,14 +1,188 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnDestroy, + OnInit, + ViewChild, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AuthService } from '../../core/services/auth.service'; +import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service'; +import { EventStatusStore } from '../../core/services/event-status.store'; +import { ChallengesStore } from './challenges.store'; +import { CategoryColumnComponent } from './category-column.component'; +import { ChallengeModalComponent } from './challenge-modal.component'; +import { SolverRow, formatDdHhMm } from './challenges.pure'; @Component({ selector: 'app-challenges-page', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, CategoryColumnComponent, ChallengeModalComponent], + styles: [` + :host { display: block; } + .wrap { padding: 12px 0; } + .grid { + display: flex; gap: 12px; + overflow-x: auto; overflow-y: visible; + padding-bottom: 12px; + } + .gate { + display: flex; align-items: center; justify-content: center; + min-height: 240px; flex-direction: column; gap: 8px; + padding: 32px; + } + .gate h2 { margin: 0; } + .countdown { + font-variant-numeric: tabular-nums; font-size: 28px; font-weight: 700; + letter-spacing: 0.04em; + } + .label { opacity: 0.7; } + .error { color: var(--color-danger, #ef4444); } + `], template: ` -
-

Challenges

-

Challenge list will appear here.

+
+ +

Challenges

+
+ +
+
+ + +
+

Event starts in

+

Event ended

+

Awaiting configuration

+
+ {{ countdownText() }} +
+
The board will be available once the event is running.
+
The board is closed.
+
+
+ +
{{ e }}
+ +
`, }) -export class ChallengesPage {} +export class ChallengesPage implements OnInit, OnDestroy { + readonly store = inject(ChallengesStore); + private readonly eventStatus = inject(EventStatusStore); + private readonly auth = inject(AuthService); + private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService); + private readonly destroyRef = inject(DestroyRef); + + @ViewChild('modal') modalRef?: ChallengeModalComponent; + + readonly eventState = this.eventStatus.state; + readonly secondsToStart = this.eventStatus.secondsToStartPublic; + readonly secondsToEnd = this.eventStatus.secondsToEndPublic; + + readonly isRunning = computed(() => this.eventState() === 'running'); + readonly canSubmit = computed(() => this.eventStatus.state() === 'running'); + readonly modalNotice = computed(() => + this.eventStatus.state() === 'running' + ? null + : 'Submissions are only accepted while the event is running.', + ); + + readonly countdownText = computed(() => { + const s = this.eventState(); + if (s === 'countdown') { + return formatDdHhMm(this.secondsToStart() ?? 0); + } + if (s === 'running') { + return formatDdHhMm(this.secondsToEnd() ?? 0); + } + if (s === 'stopped') return 'Event ended'; + return ''; + }); + + readonly selectedCard = signal>(null); + readonly selectedSolvers = signal([]); + + private sseWired = false; + private currentSolveUnsub: (() => void) | null = null; + + constructor() { + this.destroyRef.onDestroy(() => this.store.stop()); + } + + ngOnInit(): void { + const user = this.auth.currentUser(); + if (user?.id) this.store.setMyUserId(user.id); + void this.store.load(); + + // REST snapshot first so the page has synchronous state even if the SSE + // stream hasn't delivered its first frame yet (e.g. slow networks, mobile). + this.store.fetchEventState().subscribe({ + next: (payload) => this.eventStatus.applyServerStatus(payload), + error: () => undefined, + }); + + if (!this.sseWired) { + this.sseWired = true; + this.store.wireSse( + () => this.authenticatedEventSource.open('/api/v1/events'), + ); + } + this.eventStatus.reloadAtCountdownZero(() => { + if (typeof window !== 'undefined') window.location.reload(); + }); + } + + ngOnDestroy(): void { + this.store.stop(); + this.currentSolveUnsub?.(); + } + + trackByCol = (_idx: number, col: { id: string }) => col.id; + + openChallenge(id: string): void { + const card = this.store.findCard(id); + this.selectedCard.set(card); + this.selectedSolvers.set([]); + this.currentSolveUnsub?.(); + this.currentSolveUnsub = this.store.registerSolveListener(id, (payload) => { + this.modalRef?.applyLiveSolve(payload); + }); + void this.store.loadDetail(id).then((detail) => { + if (!detail) return; + if (this.selectedCard()?.id !== id) return; + this.selectedSolvers.set(detail.solvers); + }); + } + + closeChallenge(): void { + this.selectedCard.set(null); + this.selectedSolvers.set([]); + this.currentSolveUnsub?.(); + this.currentSolveUnsub = null; + this.store.clearSelectedDetail(); + } + + onSubmitted(): void { + // No-op: the store already mutated the board; the modal updates itself. + } +} diff --git a/frontend/src/app/features/challenges/challenges.pure.ts b/frontend/src/app/features/challenges/challenges.pure.ts new file mode 100644 index 0000000..e3eae6c --- /dev/null +++ b/frontend/src/app/features/challenges/challenges.pure.ts @@ -0,0 +1,254 @@ +export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH'; + +export interface BoardCard { + id: string; + name: string; + descriptionMd: string; + categoryId: string; + categoryAbbreviation: string; + categoryIconPath: string; + difficulty: Difficulty; + livePoints: number; + solveCount: number; + solvedByMe: boolean; +} + +export interface CategoryColumn { + id: string; + abbreviation: string; + name: string; + iconPath: string; + cards: BoardCard[]; +} + +export interface BoardResponse { + columns: CategoryColumn[]; + solvedChallengeIds: string[]; + perChallengeLivePoints: Record; +} + +export interface SolverRow { + position: number; + playerId: string; + playerName: string; + solvedAtUtc: string; + awardedPoints: number; + basePoints: number; + rankBonus: number; + isFirst: boolean; + isSecond: boolean; + isThird: boolean; +} + +export interface AwardedSolve { + position: number; + basePoints: number; + rankBonus: number; + awardedPoints: number; + awardedAtUtc: string; +} + +export interface SolveResponse { + status: 'solved' | 'already_solved'; + challenge: BoardCard; + awarded: AwardedSolve; + solvers: SolverRow[]; +} + +export interface SolveEventPayload { + challengeId: string; + playerId: string; + playerName: string; + awardedPoints: number; + rankBonus: number; + awardedAtUtc: string; + position: number; +} + +export interface SolveEventLivePayload extends SolveEventPayload { + livePoints: number; + solveCount: number; + initialPoints: number; + minimumPoints: number; + decaySolves: number; +} + +export interface ChallengeDetail { + challenge: BoardCard; + solvers: SolverRow[]; +} + +export interface EventStatePayloadLike { + state: 'running' | 'countdown' | 'stopped' | 'unconfigured'; + serverNowUtc: string; + eventStartUtc: string | null; + eventEndUtc: string | null; + secondsToStart: number | null; + secondsToEnd: number | null; +} + +export const DIFFICULTY_RANK: Readonly> = { + LOW: 0, + MEDIUM: 1, + HIGH: 2, +}; + +export function compareDifficulty(a: Difficulty, b: Difficulty): number { + return DIFFICULTY_RANK[a] - DIFFICULTY_RANK[b]; +} + +export function sortCardsInColumn(cards: readonly BoardCard[]): BoardCard[] { + return [...cards].sort((a, b) => { + const d = compareDifficulty(a.difficulty, b.difficulty); + if (d !== 0) return d; + const an = (a.name ?? '').toLowerCase(); + const bn = (b.name ?? '').toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return 0; + }); +} + +export function sortColumnsByAbbrev(columns: readonly CategoryColumn[]): CategoryColumn[] { + return [...columns].sort((a, b) => { + const al = (a.abbreviation ?? '').toLowerCase(); + const bl = (b.abbreviation ?? '').toLowerCase(); + if (al < bl) return -1; + if (al > bl) return 1; + return 0; + }); +} + +export function formatDdHhMm(totalSeconds: number): string { + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00'; + const s = Math.floor(totalSeconds); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${pad(days)}:${pad(hours)}:${pad(minutes)}`; +} + +export function formatUtcToLocal(utc: string | null | undefined): string { + if (!utc) return ''; + const d = new Date(utc); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString(); +} + +export type SolveErrorCode = + | 'not_running' + | 'incorrect' + | 'already_solved' + | 'not_found' + | 'unauthorized' + | 'forbidden' + | 'validation' + | 'network' + | 'unknown'; + +export function parseSolveError(status: number, body: any): SolveErrorCode { + const code = body?.code as string | undefined; + if (code === 'EVENT_NOT_RUNNING') return 'not_running'; + if (code === 'FLAG_INCORRECT') return 'incorrect'; + if (status === 404 || code === 'NOT_FOUND') return 'not_found'; + if (status === 401 || code === 'UNAUTHORIZED') return 'unauthorized'; + if (status === 403 || code === 'FORBIDDEN') return 'forbidden'; + if (status === 0) return 'network'; + if (code === 'VALIDATION_FAILED') return 'validation'; + return 'unknown'; +} + +export function parseStatusPayload(raw: any): EventStatePayloadLike { + if (!raw || typeof raw !== 'object') { + return { + state: 'unconfigured', + serverNowUtc: new Date().toISOString(), + eventStartUtc: null, + eventEndUtc: null, + secondsToStart: null, + secondsToEnd: null, + }; + } + const eventStartUtc = + raw.event_start_utc !== undefined ? raw.event_start_utc : raw.eventStartUtc ?? null; + const eventEndUtc = + raw.event_end_utc !== undefined ? raw.event_end_utc : raw.eventEndUtc ?? null; + return { + state: (raw.state as EventStatePayloadLike['state']) ?? 'unconfigured', + serverNowUtc: raw.server_time_utc ?? raw.serverNowUtc ?? new Date().toISOString(), + eventStartUtc, + eventEndUtc, + secondsToStart: raw.seconds_to_start ?? raw.secondsToStart ?? null, + secondsToEnd: raw.seconds_to_end ?? raw.secondsToEnd ?? null, + }; +} + +export function parseSolveEvent(raw: any): SolveEventLivePayload { + const awardedAtUtc = raw?.awarded_at_utc ?? raw?.awardedAtUtc ?? raw?.solvedAt ?? ''; + const playerId = raw?.player_id ?? raw?.playerId ?? raw?.userId ?? ''; + return { + challengeId: raw?.challenge_id ?? raw?.challengeId ?? '', + playerId, + playerName: raw?.player_name ?? raw?.playerName ?? '', + awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0), + rankBonus: Number(raw?.rank_bonus ?? raw?.rankBonus ?? 0), + awardedAtUtc, + position: Number(raw?.position ?? 0), + livePoints: Number(raw?.live_points ?? raw?.livePoints ?? 0), + solveCount: Number(raw?.solve_count ?? raw?.solveCount ?? 0), + initialPoints: Number(raw?.initial_points ?? raw?.initialPoints ?? 0), + minimumPoints: Number(raw?.minimum_points ?? raw?.minimumPoints ?? 0), + decaySolves: Number(raw?.decay_solves ?? raw?.decaySolves ?? 0), + }; +} + +export function messageForSolveError(code: SolveErrorCode): string { + switch (code) { + case 'not_running': + return 'Submissions are only accepted while the event is running.'; + case 'incorrect': + return 'Incorrect flag. Try again.'; + case 'already_solved': + return 'You already solved this challenge.'; + case 'not_found': + return 'Challenge not found.'; + case 'unauthorized': + return 'Your session has expired. Please sign in again.'; + case 'forbidden': + return 'You are not allowed to perform this action.'; + case 'network': + return 'Network error. Please try again.'; + case 'validation': + return 'Invalid submission.'; + default: + return 'Unexpected error. Please try again.'; + } +} + +export function mergeSolveEventIntoSolvers( + existing: readonly SolverRow[], + payload: SolveEventPayload, +): SolverRow[] { + if (existing.some((s) => s.playerId === payload.playerId && s.solvedAtUtc === payload.awardedAtUtc)) { + return [...existing]; + } + const newRow: SolverRow = { + position: 0, + playerId: payload.playerId, + playerName: payload.playerName, + solvedAtUtc: payload.awardedAtUtc, + awardedPoints: payload.awardedPoints, + basePoints: 0, + rankBonus: payload.rankBonus, + isFirst: payload.position === 1, + isSecond: payload.position === 2, + isThird: payload.position === 3, + }; + const merged = [...existing, newRow].sort((a, b) => { + const at = new Date(a.solvedAtUtc).getTime(); + const bt = new Date(b.solvedAtUtc).getTime(); + return at - bt; + }); + return merged.map((r, idx) => ({ ...r, position: idx + 1 })); +} diff --git a/frontend/src/app/features/challenges/challenges.service.ts b/frontend/src/app/features/challenges/challenges.service.ts new file mode 100644 index 0000000..879d929 --- /dev/null +++ b/frontend/src/app/features/challenges/challenges.service.ts @@ -0,0 +1,65 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, catchError, throwError } from 'rxjs'; +import { + BoardResponse, + ChallengeDetail, + EventStatePayloadLike, + SolveResponse, +} from './challenges.pure'; + +export interface ApiErrorEnvelope { + status: number; + code?: string; + message?: string; + details?: unknown; +} + +function toEnvelope(err: unknown): ApiErrorEnvelope { + if (err instanceof HttpErrorResponse) { + return { + status: err.status, + code: err.error?.code, + message: err.error?.message, + details: err.error?.details, + }; + } + return { status: 0, code: 'NETWORK', message: (err as Error)?.message }; +} + +@Injectable({ providedIn: 'root' }) +export class ChallengesApiService { + private readonly http = inject(HttpClient); + + getBoard(opts?: { includeSolvers?: boolean }): Observable { + const params: Record = {}; + if (opts?.includeSolvers) params['include'] = 'solvers'; + return this.http + .get('/api/v1/challenges/board', { withCredentials: true, params }) + .pipe(catchError((err) => throwError(() => toEnvelope(err)))); + } + + getDetail(challengeId: string): Observable { + return this.http + .get(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, { + withCredentials: true, + }) + .pipe(catchError((err) => throwError(() => toEnvelope(err)))); + } + + getEventState(): Observable { + return this.http + .get('/api/v1/challenges/status', { withCredentials: true }) + .pipe(catchError((err) => throwError(() => toEnvelope(err)))); + } + + submit(challengeId: string, flag: string): Observable { + return this.http + .post( + `/api/v1/challenges/${encodeURIComponent(challengeId)}/solves`, + { flag }, + { withCredentials: true }, + ) + .pipe(catchError((err) => throwError(() => toEnvelope(err)))); + } +} diff --git a/frontend/src/app/features/challenges/challenges.store.ts b/frontend/src/app/features/challenges/challenges.store.ts new file mode 100644 index 0000000..c205940 --- /dev/null +++ b/frontend/src/app/features/challenges/challenges.store.ts @@ -0,0 +1,333 @@ +import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + BoardCard, + BoardResponse, + CategoryColumn, + ChallengeDetail, + EventStatePayloadLike, + SolveEventLivePayload, + SolveEventPayload, + SolveResponse, + SolverRow, + mergeSolveEventIntoSolvers, + parseSolveEvent, + sortCardsInColumn, + sortColumnsByAbbrev, +} from './challenges.pure'; +import { ChallengesApiService } from './challenges.service'; +import { EventSourceLike } from '../../core/services/event-status.pure'; + +@Injectable({ providedIn: 'root' }) +export class ChallengesStore { + private readonly api: ChallengesApiService; + private readonly destroyRef: DestroyRef; + + private readonly _board = signal(null); + private readonly _eventState = signal('unconfigured'); + private readonly _secondsToStart = signal(null); + private readonly _secondsToEnd = signal(null); + private readonly _loading = signal(false); + private readonly _error = signal(null); + private readonly _tick = signal(0); + private readonly _myUserId = signal(null); + private readonly _selectedDetail = signal(null); + + private readonly solveListeners = new Map void>>(); + + constructor(api?: ChallengesApiService, destroyRef?: DestroyRef) { + this.api = api ?? inject(ChallengesApiService); + this.destroyRef = destroyRef ?? inject(DestroyRef); + this.destroyRef.onDestroy(() => this.stop()); + } + + readonly board = this._board.asReadonly(); + readonly loading = this._loading.asReadonly(); + readonly error = this._error.asReadonly(); + readonly eventState = this._eventState.asReadonly(); + readonly selectedChallengeDetail = this._selectedDetail.asReadonly(); + + readonly sortedColumns = computed(() => { + const b = this._board(); + if (!b) return []; + return sortColumnsByAbbrev( + b.columns.map((col) => ({ + ...col, + cards: sortCardsInColumn(col.cards), + })), + ); + }); + + readonly isRunning = computed(() => this._eventState() === 'running'); + + readonly countdownSeconds = computed(() => { + void this._tick(); + const state = this._eventState(); + if (state === 'countdown') return this._secondsToStart(); + if (state === 'running') return this._secondsToEnd(); + return null; + }); + + private intervalId: ReturnType | null = null; + private sse: EventSourceLike | null = null; + private countdownZeroHandler: (() => void) | null = null; + + setMyUserId(id: string | null): void { + this._myUserId.set(id); + } + + async load(): Promise { + this._loading.set(true); + this._error.set(null); + try { + const board = await new Promise((resolve, reject) => { + this.api.getBoard().subscribe({ next: resolve, error: reject }); + }); + this._board.set(board); + } catch (err: any) { + this._error.set(err?.message ?? 'Failed to load challenges'); + } finally { + this._loading.set(false); + } + } + + fetchEventState(): Observable { + return this.api.getEventState(); + } + + async loadDetail(id: string): Promise { + try { + const detail = await new Promise((resolve, reject) => { + this.api.getDetail(id).subscribe({ next: resolve, error: reject }); + }); + this._selectedDetail.set(detail); + this.applySubmitResponse({ + status: 'solved', + challenge: detail.challenge, + awarded: { + position: 0, + basePoints: 0, + rankBonus: 0, + awardedPoints: 0, + awardedAtUtc: '', + }, + solvers: detail.solvers, + }); + return detail; + } catch { + this._selectedDetail.set(null); + return null; + } + } + + clearSelectedDetail(): void { + this._selectedDetail.set(null); + } + + registerSolveListener(challengeId: string, listener: (p: SolveEventLivePayload) => void): () => void { + const set = this.solveListeners.get(challengeId) ?? new Set(); + set.add(listener); + this.solveListeners.set(challengeId, set); + return () => { + const cur = this.solveListeners.get(challengeId); + if (!cur) return; + cur.delete(listener); + if (cur.size === 0) this.solveListeners.delete(challengeId); + }; + } + + applyStatusPayload(payload: EventStatePayloadLike): void { + this._eventState.set(payload.state); + this._secondsToStart.set(payload.secondsToStart); + this._secondsToEnd.set(payload.secondsToEnd); + } + + wireSse(createSource: () => EventSourceLike, onUnauthorized?: () => void): void { + this.stop(); + const src = createSource(); + this.sse = src; + // The challenges store only cares about 'solve' frames. The status stream is + // consumed separately by EventStatusStore (via its own 'message' listener). + // Registering on multiple event names would cause each frame to be dispatched + // twice (the transport fan-outs to both the named listener and 'message'). + const handler = (ev: MessageEvent | Event) => this.handleSseFrame(ev); + src.addEventListener('solve', handler); + if (onUnauthorized) { + src.addEventListener('unauthorized', () => { + try { + onUnauthorized(); + } catch { + // ignore + } + }); + } + if (!this.intervalId) { + this.intervalId = setInterval(() => this._tick.update((v) => v + 1), 1000); + } + queueMicrotask(() => this.checkCountdownZero()); + } + + /** Exposed for tests; safe to call externally as it is read-only on state. */ + handleSseFrame(ev: MessageEvent | Event): void { + const me = ev as MessageEvent; + if (me.type !== 'solve') return; + try { + const raw = typeof me.data === 'string' ? JSON.parse(me.data) : me.data; + if (!raw) return; + const payload = parseSolveEvent(raw); + this.applySolveEvent(payload); + const listeners = this.solveListeners.get(payload.challengeId); + if (listeners) { + for (const fn of listeners) { + try { fn(payload); } catch { /* ignore */ } + } + } + } catch { + // ignore malformed frame + } + } + + onCountdownZero(handler: () => void): void { + this.countdownZeroHandler = handler; + } + + private checkCountdownZero(): void { + const state = this._eventState(); + if (state === 'countdown') { + const s = this._secondsToStart(); + if (s !== null && s <= 0 && this.countdownZeroHandler) { + this.countdownZeroHandler(); + } + } else if (state === 'running') { + const s = this._secondsToEnd(); + if (s !== null && s <= 0 && this.countdownZeroHandler) { + this.countdownZeroHandler(); + } + } + if (this.intervalId) { + setTimeout(() => this.checkCountdownZero(), 1000); + } + } + + applySolveEvent(payload: SolveEventLivePayload): void { + const board = this._board(); + const me = this._myUserId(); + const isMine = me !== null && payload.playerId === me; + if (board) { + const updated: BoardResponse = { + ...board, + columns: board.columns.map((col) => ({ + ...col, + cards: col.cards.map((card): BoardCard => { + if (card.id !== payload.challengeId) return card; + return { + ...card, + livePoints: payload.livePoints ?? card.livePoints, + solveCount: payload.solveCount ?? card.solveCount + 1, + solvedByMe: card.solvedByMe || isMine, + }; + }), + })), + solvedChallengeIds: isMine && !board.solvedChallengeIds.includes(payload.challengeId) + ? [...board.solvedChallengeIds, payload.challengeId] + : board.solvedChallengeIds, + perChallengeLivePoints: payload.livePoints != null + ? { ...board.perChallengeLivePoints, [payload.challengeId]: payload.livePoints } + : board.perChallengeLivePoints, + }; + this._board.set(updated); + } + const detail = this._selectedDetail(); + if (detail && detail.challenge.id === payload.challengeId) { + const livePoints = payload.livePoints ?? detail.challenge.livePoints; + const solveCount = payload.solveCount ?? detail.challenge.solveCount + 1; + this._selectedDetail.set({ + challenge: { + ...detail.challenge, + livePoints, + solveCount, + solvedByMe: detail.challenge.solvedByMe || isMine, + }, + solvers: mergeSolveEventIntoSolvers(detail.solvers, payload), + }); + } + } + + applySubmitResponse(response: SolveResponse): void { + const board = this._board(); + if (board) { + const updated: BoardResponse = { + ...board, + columns: board.columns.map((col) => ({ + ...col, + cards: col.cards.map((card): BoardCard => { + if (card.id !== response.challenge.id) return card; + return { + ...card, + livePoints: response.challenge.livePoints, + solveCount: response.challenge.solveCount, + solvedByMe: response.challenge.solvedByMe || card.solvedByMe, + }; + }), + })), + solvedChallengeIds: response.challenge.solvedByMe && !board.solvedChallengeIds.includes(response.challenge.id) + ? [...board.solvedChallengeIds, response.challenge.id] + : board.solvedChallengeIds, + perChallengeLivePoints: { + ...board.perChallengeLivePoints, + [response.challenge.id]: response.challenge.livePoints, + }, + }; + this._board.set(updated); + } + } + + findCard(id: string): BoardCard | null { + const board = this._board(); + if (!board) return null; + for (const col of board.columns) { + const card = col.cards.find((c) => c.id === id); + if (card) return card; + } + return null; + } + + stop(): void { + if (this.sse) { + try { + this.sse.close(); + } catch { + // ignore + } + this.sse = null; + } + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + async submit(challengeId: string, flag: string): Promise { + return new Promise((resolve, reject) => { + this.api.submit(challengeId, flag).subscribe({ + next: (resp) => { + this.applySubmitResponse(resp); + resolve(resp); + }, + error: (err) => reject(err), + }); + }); + } + + buildSolversFromResponse(solvers: SolverRow[]): SolverRow[] { + return [...solvers].sort((a, b) => { + const at = new Date(a.solvedAtUtc).getTime(); + const bt = new Date(b.solvedAtUtc).getTime(); + return at - bt; + }).map((s, idx) => ({ ...s, position: idx + 1 })); + } + + mergeLiveSolver(solvers: SolverRow[], payload: SolveEventPayload): SolverRow[] { + return mergeSolveEventIntoSolvers(solvers, payload); + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 3b4c2a0..58e2cf3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,10 +6,13 @@ import { AppComponent } from './app/app.component'; import { APP_ROUTES } from './app/app.routes'; import { authInterceptor } from './app/core/interceptors/auth.interceptor'; import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor'; +import { errorNotificationInterceptor } from './app/core/interceptors/error-notification.interceptor'; bootstrapApplication(AppComponent, { providers: [ - provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])), + provideHttpClient( + withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor]), + ), provideRouter(APP_ROUTES, withComponentInputBinding()), ], }).catch((err) => console.error(err)); \ No newline at end of file diff --git a/tests/backend/challenges-board.spec.ts b/tests/backend/challenges-board.spec.ts new file mode 100644 index 0000000..6cf8639 --- /dev/null +++ b/tests/backend/challenges-board.spec.ts @@ -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 { + await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString()); + await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString()); +} + +describe('GET /api/v1/challenges/board (authenticated)', () => { + let app: INestApplication; + let adminToken: string; + let adminCsrf: string; + let settings: SettingsService; + let challengeRepo: Repository; + let categoryRepo: Repository; + let cry: CategoryEntity; + let web: CategoryEntity; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + settings = app.get(SettingsService); + challengeRepo = app.get>(getRepositoryToken(ChallengeEntity)); + categoryRepo = app.get>(getRepositoryToken(CategoryEntity)); + + cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; + web = (await categoryRepo.findOne({ where: { systemKey: 'WEB' } }))!; + if (!cry || !web) throw new Error('expected CRY and WEB seeded categories'); + + // Seed: 2 challenges under CRY (LOW, HIGH) and 1 under WEB (MEDIUM); one disabled. + await challengeRepo.save([ + challengeRepo.create({ + id: uuid(), + name: 'alphacipher', + descriptionMd: '# Alpha\n', + categoryId: cry.id, + difficulty: 'LOW', + initialPoints: 200, + minimumPoints: 100, + decaySolves: 5, + flag: 'flag{alpha}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: true, + }), + challengeRepo.create({ + id: uuid(), + name: 'Zeta-key', + descriptionMd: '# Zeta\n', + categoryId: cry.id, + difficulty: 'HIGH', + initialPoints: 500, + minimumPoints: 100, + decaySolves: 10, + flag: 'flag{zeta}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: true, + }), + challengeRepo.create({ + id: uuid(), + name: 'webmid', + descriptionMd: '# web\n', + categoryId: web.id, + difficulty: 'MEDIUM', + initialPoints: 300, + minimumPoints: 50, + decaySolves: 5, + flag: 'flag{web}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: true, + }), + challengeRepo.create({ + id: uuid(), + name: 'hidden', + descriptionMd: 'hidden', + categoryId: cry.id, + difficulty: 'LOW', + initialPoints: 100, + minimumPoints: 50, + decaySolves: 5, + flag: 'flag{hidden}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: false, + }), + ]); + + await setRunningWindow(settings); + + // Bootstrap admin and login. + const server = app.getHttpServer(); + await request(server).post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + const csrfPrime = await request(server).get('/api/v1/auth/csrf'); + const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf=')); + adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : ''; + const login = await request(server).post('/api/v1/auth/login') + .set('Cookie', `csrf=${adminCsrf}`) + .set('X-CSRF-Token', adminCsrf) + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + adminToken = login.body.accessToken; + }); + + afterAll(async () => { + await app.close(); + }); + + it('rejects unauthenticated requests with 401', async () => { + await request(app.getHttpServer()).get('/api/v1/challenges/board').expect(401); + }); + + it('returns columns sorted by abbreviation with cards sorted by difficulty then name', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/challenges/board') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + expect(Array.isArray(res.body.columns)).toBe(true); + const abbrs = res.body.columns.map((c: any) => c.abbreviation); + expect(abbrs).toEqual([...abbrs].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))); + + const cryCol = res.body.columns.find((c: any) => c.abbreviation === 'CRY'); + expect(cryCol).toBeDefined(); + const difficulties = cryCol.cards.map((card: any) => card.difficulty); + expect(difficulties).toEqual(['LOW', 'HIGH']); + const enabledCardIds = res.body.columns.flatMap((c: any) => c.cards.map((card: any) => card.id)); + // hidden should be excluded + expect(enabledCardIds).toHaveLength(3); + }); + + it('strips the flag from every response (no plaintext leak)', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/challenges/board') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const json = JSON.stringify(res.body); + expect(json).not.toContain('flag{alpha}'); + expect(json).not.toContain('flag{zeta}'); + expect(json).not.toContain('flag{web}'); + expect(json).not.toContain('flag{hidden}'); + expect(json).not.toContain('"flag"'); + }); + + it('omits per-card solvers by default (keeps payload small)', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/challenges/board') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const allCards: any[] = res.body.columns.flatMap((c: any) => c.cards); + for (const card of allCards) { + expect(card.solvers).toBeUndefined(); + } + }); + + it('GET /api/v1/challenges/:id returns detail with solvers + no flag leak', async () => { + const list = await request(app.getHttpServer()) + .get('/api/v1/challenges/board') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const firstCardId: string = list.body.columns[0].cards[0].id; + const res = await request(app.getHttpServer()) + .get(`/api/v1/challenges/${firstCardId}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + expect(res.body.challenge.id).toBe(firstCardId); + expect(Array.isArray(res.body.solvers)).toBe(true); + const json = JSON.stringify(res.body); + expect(json).not.toContain('"flag"'); + expect(json).not.toContain('flag{'); + }); + + it('?include=solvers attaches per-card solvers sorted by solve time', async () => { + const list = await request(app.getHttpServer()) + .get('/api/v1/challenges/board') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const cardId: string = list.body.columns[0].cards[0].id; + // Solve it via the submit endpoint to populate solvers. + await request(app.getHttpServer()) + .post(`/api/v1/challenges/${cardId}/solves`) + .set('Cookie', `csrf=${adminCsrf}`) + .set('X-CSRF-Token', adminCsrf) + .set('Authorization', `Bearer ${adminToken}`) + .send({ flag: list.body.columns[0].cards[0].name === 'alphacipher' ? 'flag{alpha}' : 'flag{zeta}' }) + .expect(200); + + const res = await request(app.getHttpServer()) + .get('/api/v1/challenges/board?include=solvers') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const cardWithSolvers: any = res.body.columns + .flatMap((c: any) => c.cards) + .find((card: any) => card.id === cardId); + expect(cardWithSolvers.solvers).toBeDefined(); + expect(Array.isArray(cardWithSolvers.solvers)).toBe(true); + expect(cardWithSolvers.solvers[0]).toHaveProperty('position'); + expect(cardWithSolvers.solvers[0]).toHaveProperty('playerName'); + expect(cardWithSolvers.solvers[0]).toHaveProperty('awardedPoints'); + const json = JSON.stringify(res.body); + expect(json).not.toContain('"flag"'); + expect(json).not.toContain('flag{'); + }); +}); diff --git a/tests/backend/challenges-events-sse.spec.ts b/tests/backend/challenges-events-sse.spec.ts new file mode 100644 index 0000000..54b8de1 --- /dev/null +++ b/tests/backend/challenges-events-sse.spec.ts @@ -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); +}); diff --git a/tests/backend/challenges-status-rest.spec.ts b/tests/backend/challenges-status-rest.spec.ts new file mode 100644 index 0000000..d00f026 --- /dev/null +++ b/tests/backend/challenges-status-rest.spec.ts @@ -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); + }); +}); diff --git a/tests/backend/challenges-submit-flag.spec.ts b/tests/backend/challenges-submit-flag.spec.ts new file mode 100644 index 0000000..688aea7 --- /dev/null +++ b/tests/backend/challenges-submit-flag.spec.ts @@ -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 { + await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString()); + await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString()); +} + +async function setStoppedWindow(svc: SettingsService): Promise { + await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 7 * 24 * 3600_000).toISOString()); + await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() - 24 * 3600_000).toISOString()); +} + +describe('POST /api/v1/challenges/:id/solves', () => { + let app: INestApplication; + let adminToken: string; + let adminCsrf: string; + let playerAgent: any; + let playerToken: string; + let playerCsrf: string; + let player2Agent: any; + let player2Token: string; + let player2Csrf: string; + let settings: SettingsService; + let challengeRepo: Repository; + let solveRepo: Repository; + let userRepo: Repository; + let categoryRepo: Repository; + let challenge: ChallengeEntity; + let challenge2: ChallengeEntity; + let playerId: string; + let player2Id: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + settings = app.get(SettingsService); + challengeRepo = app.get>(getRepositoryToken(ChallengeEntity)); + solveRepo = app.get>(getRepositoryToken(SolveEntity)); + userRepo = app.get>(getRepositoryToken(UserEntity)); + categoryRepo = app.get>(getRepositoryToken(CategoryEntity)); + + const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; + if (!cry) throw new Error('expected CRY seeded category'); + + challenge = await challengeRepo.save( + challengeRepo.create({ + id: uuid(), + name: 'alphacipher', + descriptionMd: '# Alpha\n', + categoryId: cry.id, + difficulty: 'LOW', + initialPoints: 500, + minimumPoints: 100, + decaySolves: 2, + flag: 'flag{alpha}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: true, + }), + ); + challenge2 = await challengeRepo.save( + challengeRepo.create({ + id: uuid(), + name: 'disabled-chal', + descriptionMd: '', + categoryId: cry.id, + difficulty: 'LOW', + initialPoints: 100, + minimumPoints: 50, + decaySolves: 5, + flag: 'flag{x}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: false, + }), + ); + + await setRunningWindow(settings); + + const server = app.getHttpServer(); + await request(server).post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + const csrfPrime = await request(server).get('/api/v1/auth/csrf'); + const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf=')); + adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : ''; + const adminLogin = await request(server).post('/api/v1/auth/login') + .set('Cookie', `csrf=${adminCsrf}`) + .set('X-CSRF-Token', adminCsrf) + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + adminToken = adminLogin.body.accessToken; + + // Create two player users directly. + const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id }); + const player = await userRepo.save(userRepo.create({ + id: uuid(), + username: 'player1', + passwordHash, + role: 'player', + status: 'enabled', + })); + playerId = player.id; + const player2 = await userRepo.save(userRepo.create({ + id: uuid(), + username: 'player2', + passwordHash, + role: 'player', + status: 'enabled', + })); + player2Id = player2.id; + + // Login player1. + { + playerAgent = request.agent(server); + await playerAgent.get('/api/v1/auth/csrf'); + const cookies: any = playerAgent.jar.getCookies(CookieAccessInfo.All); + playerCsrf = cookies.find((c: any) => c.name === 'csrf').value; + const login = await playerAgent.post('/api/v1/auth/login') + .set('X-CSRF-Token', playerCsrf) + .send({ username: 'player1', password: 'PlayerPass!1Ab' }) + .expect(201); + playerToken = login.body.accessToken; + } + // Login player2. + { + player2Agent = request.agent(server); + await player2Agent.get('/api/v1/auth/csrf'); + const cookies: any = player2Agent.jar.getCookies(CookieAccessInfo.All); + player2Csrf = cookies.find((c: any) => c.name === 'csrf').value; + const login = await player2Agent.post('/api/v1/auth/login') + .set('X-CSRF-Token', player2Csrf) + .send({ username: 'player2', password: 'PlayerPass!1Ab' }) + .expect(201); + player2Token = login.body.accessToken; + } + }); + + afterAll(async () => { + await app.close(); + }); + + function submit(agent: any, token: string, csrf: string, challengeId: string, flag: string) { + return agent + .post(`/api/v1/challenges/${challengeId}/solves`) + .set('Authorization', `Bearer ${token}`) + .set('X-CSRF-Token', csrf) + .send({ flag }); + } + + it('rejects unauthenticated requests (CSRF check fires first, returns 403)', async () => { + await request(app.getHttpServer()) + .post(`/api/v1/challenges/${challenge.id}/solves`) + .send({ flag: 'flag{alpha}' }) + .expect(403); + }); + + it('rejects unknown challenge with 404 and never writes a row', async () => { + const res = await submit(playerAgent, playerToken, playerCsrf, uuid(), 'flag{alpha}'); + expect(res.status).toBe(404); + expect(res.body?.code).toBe('NOT_FOUND'); + const count = await solveRepo.count(); + expect(count).toBe(0); + }); + + it('rejects disabled challenge with 404', async () => { + const res = await submit(playerAgent, playerToken, playerCsrf, challenge2.id, 'flag{x}'); + expect(res.status).toBe(404); + }); + + it('rejects wrong flag with 400 FLAG_INCORRECT and does not write a row', async () => { + const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{wrong}'); + expect(res.status).toBe(400); + expect(res.body?.code).toBe('FLAG_INCORRECT'); + const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); + expect(count).toBe(0); + }); + + it('rejects submission when event is not running', async () => { + await setStoppedWindow(settings); + const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); + expect(res.status).toBe(409); + expect(res.body?.code).toBe('EVENT_NOT_RUNNING'); + const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); + expect(count).toBe(0); + await setRunningWindow(settings); + }); + + it('awards first-blood bonus (base + 15) to the first solver and writes a single row', async () => { + const before = await solveRepo.count(); + const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('solved'); + expect(res.body.awarded.position).toBe(1); + expect(res.body.awarded.rankBonus).toBe(15); + expect(res.body.awarded.basePoints).toBe(500); + expect(res.body.awarded.awardedPoints).toBe(515); + expect(res.body.challenge.solveCount).toBe(1); + const count = await solveRepo.count(); + expect(count).toBe(before + 1); + }); + + it('is idempotent on a second correct submission from the same player (no new row, same awarded points)', async () => { + const before = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); + const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('already_solved'); + expect(res.body.awarded.awardedPoints).toBe(515); + const after = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } }); + expect(after).toBe(before); + }); + + it('does not leak the flag in any response', async () => { + const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}'); + const json = JSON.stringify(res.body); + expect(json).not.toContain('flag{alpha}'); + expect(json).not.toContain('"flag"'); + }); + + it('awards second-blood bonus (base + 10) to the second solver', async () => { + const res = await submit(player2Agent, player2Token, player2Csrf, challenge.id, 'flag{alpha}'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('solved'); + expect(res.body.awarded.position).toBe(2); + expect(res.body.awarded.rankBonus).toBe(10); + // solveCountBefore = 1, base = round(500 - (400) * (1/2)) = 300, awarded = 310 + expect(res.body.awarded.basePoints).toBe(300); + expect(res.body.awarded.awardedPoints).toBe(310); + }); + + it('two concurrent correct submissions from the same player produce exactly one row', async () => { + // Create a fresh challenge + fresh player for this concurrency test. + const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; + const c = await challengeRepo.save(challengeRepo.create({ + id: uuid(), + name: 'concurrent', + descriptionMd: '', + categoryId: cry.id, + difficulty: 'LOW', + initialPoints: 200, + minimumPoints: 100, + decaySolves: 5, + flag: 'flag{concurrent}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: true, + })); + const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id }); + const u = await userRepo.save(userRepo.create({ + id: uuid(), + username: 'concurrent_player', + passwordHash, + role: 'player', + status: 'enabled', + })); + const agent = request.agent(app.getHttpServer()); + await agent.get('/api/v1/auth/csrf'); + const cookies: any = agent.jar.getCookies(CookieAccessInfo.All); + const csrfValue = cookies.find((x: any) => x.name === 'csrf').value; + const login = await agent.post('/api/v1/auth/login') + .set('X-CSRF-Token', csrfValue) + .send({ username: 'concurrent_player', password: 'PlayerPass!1Ab' }) + .expect(201); + const token = login.body.accessToken; + + const [a, b] = await Promise.all([ + agent + .post(`/api/v1/challenges/${c.id}/solves`) + .set('Authorization', `Bearer ${token}`) + .set('X-CSRF-Token', csrfValue) + .send({ flag: 'flag{concurrent}' }), + agent + .post(`/api/v1/challenges/${c.id}/solves`) + .set('Authorization', `Bearer ${token}`) + .set('X-CSRF-Token', csrfValue) + .send({ flag: 'flag{concurrent}' }), + ]); + expect([200, 200]).toContain(a.status); + expect([200, 200]).toContain(b.status); + const solved = (a.status === 200 && a.body.status === 'solved') || (b.status === 200 && b.body.status === 'solved'); + const already = (a.status === 200 && a.body.status === 'already_solved') || (b.status === 200 && b.body.status === 'already_solved'); + expect(solved).toBe(true); + expect(already).toBe(true); + + const rows = await solveRepo.count({ where: { challengeId: c.id, userId: u.id } }); + expect(rows).toBe(1); + }); + + it('awarded_points on the immutable solve row equals base + bonus and never changes after insert', async () => { + const row = await solveRepo.findOne({ where: { challengeId: challenge.id, userId: playerId } }); + expect(row).toBeTruthy(); + expect(row!.pointsAwarded).toBe(row!.basePoints + row!.rankBonus); + expect(row!.pointsAwarded).toBe(515); + expect(row!.rankBonus).toBe(15); + expect(row!.isFirst).toBe(1); + }); + + it('emits an SSE solve payload with live_points + scoring params via SseHubService', async () => { + const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service'); + const hub = app.get(SseHubService); + const seen: any[] = []; + const sub = hub.scoreboard$().subscribe((p) => seen.push(p)); + try { + // Fresh challenge to make math deterministic. + const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; + const c = await challengeRepo.save(challengeRepo.create({ + id: uuid(), + name: 'sse-emit', + descriptionMd: '', + categoryId: cry.id, + difficulty: 'LOW', + initialPoints: 400, + minimumPoints: 100, + decaySolves: 4, + flag: 'flag{sse}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: true, + })); + await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{sse}'); + const matching = seen.filter((p) => p.challengeId === c.id); + expect(matching.length).toBeGreaterThan(0); + const latest = matching[matching.length - 1]; + expect(latest).toMatchObject({ + topic: 'solve', + challengeId: c.id, + playerId: playerId, + position: 1, + awardedPoints: 400 + 15, + rankBonus: 15, + livePoints: 325, // post-insert: count=1, round(400 - 300 * 1/4) = 325 + solveCount: 1, + initialPoints: 400, + minimumPoints: 100, + decaySolves: 4, + }); + expect(typeof latest.playerName).toBe('string'); + expect(typeof latest.awardedAtUtc).toBe('string'); + } finally { + sub.unsubscribe(); + } + }); + + it('emits the solve payload exactly once for a fresh insert (no duplicate publishes on already_solved retry)', async () => { + const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service'); + const hub = app.get(SseHubService); + const seen: any[] = []; + const sub = hub.scoreboard$().subscribe((p) => seen.push(p)); + try { + const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!; + const c = await challengeRepo.save(challengeRepo.create({ + id: uuid(), + name: 'once-only', + descriptionMd: '', + categoryId: cry.id, + difficulty: 'LOW', + initialPoints: 100, + minimumPoints: 50, + decaySolves: 5, + flag: 'flag{once}', + protocol: 'WEB', + port: null, + ipAddress: '', + enabled: true, + })); + // Subscribe AFTER subscribing to ensure we don't miss the publish. + const before = seen.filter((p) => p.challengeId === c.id).length; + await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}'); + // Retry: should be idempotent and NOT publish. + await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}'); + await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}'); + const after = seen.filter((p) => p.challengeId === c.id).length; + expect(after - before).toBe(1); + } finally { + sub.unsubscribe(); + } + }); +}); diff --git a/tests/frontend/authenticated-event-source.spec.ts b/tests/frontend/authenticated-event-source.spec.ts index 2bd7b38..78fc6d9 100644 --- a/tests/frontend/authenticated-event-source.spec.ts +++ b/tests/frontend/authenticated-event-source.spec.ts @@ -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 void>(); let unauthorizedHandler: ((ev: Event) => void) | null = null; void (async () => { const headers: Record = { 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(); + }); }); diff --git a/tests/frontend/challenges.pure.spec.ts b/tests/frontend/challenges.pure.spec.ts new file mode 100644 index 0000000..27600ec --- /dev/null +++ b/tests/frontend/challenges.pure.spec.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/tests/frontend/challenges.store.spec.ts b/tests/frontend/challenges.store.spec.ts new file mode 100644 index 0000000..39fc2f7 --- /dev/null +++ b/tests/frontend/challenges.store.spec.ts @@ -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'); + }); +}); diff --git a/tests/frontend/jest.setup.ts b/tests/frontend/jest.setup.ts index 41fb5ca..2748d82 100644 --- a/tests/frontend/jest.setup.ts +++ b/tests/frontend/jest.setup.ts @@ -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) => ({ diff --git a/tests/frontend/notification-interceptor.spec.ts b/tests/frontend/notification-interceptor.spec.ts new file mode 100644 index 0000000..fefc01f --- /dev/null +++ b/tests/frontend/notification-interceptor.spec.ts @@ -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 { + return new HttpRequest('GET', url); +} + +function ok(): (req: HttpRequest) => Observable> { + return () => + new Observable>((sub) => { + sub.next(new HttpResponse({ status: 200, body: { ok: true } })); + sub.complete(); + }); +} + +function err( + status: number, + body: any, + url: string, +): (req: HttpRequest) => Observable> { + 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, + next: (r: HttpRequest) => Observable>, +): 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); + }); +});