AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,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<EventStatePayload>`.
|
||||||
|
- `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<EventStatePayload> {
|
||||||
|
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<Notification[]>([]);
|
||||||
|
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<EventStatePayload> {
|
||||||
|
return this.http
|
||||||
|
.get<EventStatePayload>('/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).
|
||||||
@@ -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/<uuid>` for committed files, `.staging/<uuid>` 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 `<app-challenge-import-modal>` 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 `<input>` 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.
|
|
||||||
@@ -12,6 +12,7 @@ import { SettingsModule } from './modules/settings/settings.module';
|
|||||||
import { AdminModule } from './modules/admin/admin.module';
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||||
import { BlogModule } from './modules/blog/blog.module';
|
import { BlogModule } from './modules/blog/blog.module';
|
||||||
|
import { ChallengesModule } from './modules/challenges/challenges.module';
|
||||||
import { FrontendModule } from './frontend/frontend.module';
|
import { FrontendModule } from './frontend/frontend.module';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
@@ -33,6 +34,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
UploadsModule,
|
UploadsModule,
|
||||||
BlogModule,
|
BlogModule,
|
||||||
|
ChallengesModule,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const ERROR_CODES = {
|
|||||||
CHALLENGE_FILE_LIMIT: 'CHALLENGE_FILE_LIMIT',
|
CHALLENGE_FILE_LIMIT: 'CHALLENGE_FILE_LIMIT',
|
||||||
IMPORT_VALIDATION_FAILED: 'IMPORT_VALIDATION_FAILED',
|
IMPORT_VALIDATION_FAILED: 'IMPORT_VALIDATION_FAILED',
|
||||||
IMPORT_PARTIAL_FAILURE: 'IMPORT_PARTIAL_FAILURE',
|
IMPORT_PARTIAL_FAILURE: 'IMPORT_PARTIAL_FAILURE',
|
||||||
|
EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING',
|
||||||
|
FLAG_INCORRECT: 'FLAG_INCORRECT',
|
||||||
|
CHALLENGE_DISABLED: 'CHALLENGE_DISABLED',
|
||||||
INTERNAL: 'INTERNAL',
|
INTERNAL: 'INTERNAL',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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<BoardResponseDto> {
|
||||||
|
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<EventStatePayload> {
|
||||||
|
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<ChallengeDetailDto> {
|
||||||
|
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<SolveResponseDto> {
|
||||||
|
const userId = this.extractUserId(req);
|
||||||
|
return this.svc.submitFlag(userId, params.id, body.flag);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<ChallengeEntity>,
|
||||||
|
@InjectRepository(CategoryEntity)
|
||||||
|
private readonly categories: Repository<CategoryEntity>,
|
||||||
|
@InjectRepository(SolveEntity)
|
||||||
|
private readonly solves: Repository<SolveEntity>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly eventStatus: EventStatusService,
|
||||||
|
private readonly hub: SseHubService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBoard(currentUserId: string, opts: { includeSolvers?: boolean } = {}): Promise<BoardResponseDto> {
|
||||||
|
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<ChallengeRow>();
|
||||||
|
|
||||||
|
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<string, number> = {};
|
||||||
|
const byCategory = new Map<string, { cat: { id: string; abbreviation: string; name: string; iconPath: string }; cards: ChallengeBoardCardDto[] }>();
|
||||||
|
|
||||||
|
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<ChallengeDetailDto> {
|
||||||
|
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<ChallengeRow> {
|
||||||
|
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<ChallengeRow>();
|
||||||
|
if (!row || !row.enabled) throw ApiError.notFound('Challenge not found');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitFlag(currentUserId: string, challengeId: string, submittedFlag: string): Promise<SolveResponseDto> {
|
||||||
|
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<ChallengeRow & { flag: string }>();
|
||||||
|
|
||||||
|
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<SolveRow>();
|
||||||
|
|
||||||
|
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<Map<string, number>> {
|
||||||
|
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<string, number>();
|
||||||
|
for (const r of rows) map.set(r.challengeId, Number(r.count));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSolvedChallengeIds(userId: string, challengeIds: string[]): Promise<Set<string>> {
|
||||||
|
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<SolveRow[]> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof SolveSubmitBodySchema>;
|
||||||
|
|
||||||
|
export const BoardQuerySchema = z.object({
|
||||||
|
include: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type BoardQuery = z.infer<typeof BoardQuerySchema>;
|
||||||
|
|
||||||
|
export const ChallengeIdParamSchema = z.object({
|
||||||
|
id: z.string().uuid({ message: 'Invalid challenge id' }),
|
||||||
|
});
|
||||||
|
export type ChallengeIdParam = z.infer<typeof ChallengeIdParamSchema>;
|
||||||
|
|
||||||
|
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<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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: <type>\ndata: <JSON>\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<MessageEvent> {
|
||||||
|
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<MessageEvent> => !!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$);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
|
||||||
|
export const DIFFICULTY_RANK: Readonly<Record<Difficulty, number>> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: Challenges Endpoints
|
||||||
|
description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames.
|
||||||
|
tags: [api, challenges, board, score, sse, submit]
|
||||||
|
timestamp: 2026-07-23T00:10:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
All routes are mounted by `ChallengesModule`
|
||||||
|
(`backend/src/modules/challenges/challenges.module.ts`) and require
|
||||||
|
authentication (`JwtAuthGuard` is registered globally and these handlers
|
||||||
|
are not `@Public()`). The DTOs and zod schemas live in
|
||||||
|
`backend/src/modules/challenges/dto/challenges.dto.ts`.
|
||||||
|
|
||||||
|
| Method | Path | Auth | Source |
|
||||||
|
|--------|-----------------------------------|-------------|-----------------------------------------------------------|
|
||||||
|
| `GET` | `/api/v1/challenges/board` | Authenticated | `backend/src/modules/challenges/challenges.controller.ts` |
|
||||||
|
| `GET` | `/api/v1/challenges/status` | Authenticated | Same. |
|
||||||
|
| `GET` | `/api/v1/challenges/:id` | Authenticated | Same. |
|
||||||
|
| `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. |
|
||||||
|
| `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` |
|
||||||
|
|
||||||
|
> The combined `/api/v1/events` SSE is **in addition to** the existing
|
||||||
|
> `/api/v1/events/status` status-only stream. The two share the same
|
||||||
|
> authenticated transport but serve different consumers — the
|
||||||
|
> `ChallengesPage` opens `/api/v1/events` so it can react to `solve`
|
||||||
|
> frames in addition to status, while the shell header keeps reading
|
||||||
|
> `/api/v1/events/status` for the LED/countdown only.
|
||||||
|
|
||||||
|
# `GET /api/v1/challenges/board`
|
||||||
|
|
||||||
|
Authenticated board of enabled challenges grouped by category. Query
|
||||||
|
string:
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-----------|--------|----------------------------------------------------------------------------------------------|
|
||||||
|
| `include` | string | Comma-separated feature list. Passing `solvers` attaches a per-card `solvers` array (ordered by `solved_at ASC`). |
|
||||||
|
|
||||||
|
The handler (`ChallengesService.getBoard`) returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"id": "<category-uuid>",
|
||||||
|
"abbreviation": "CRY",
|
||||||
|
"name": "Cryptography",
|
||||||
|
"iconPath": "/uploads/icons/CRY.png",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "<challenge-uuid>",
|
||||||
|
"name": "RSA Rollers",
|
||||||
|
"descriptionMd": "...",
|
||||||
|
"categoryId": "<category-uuid>",
|
||||||
|
"categoryAbbreviation": "CRY",
|
||||||
|
"categoryIconPath": "/uploads/icons/CRY.png",
|
||||||
|
"difficulty": "MEDIUM",
|
||||||
|
"livePoints": 480,
|
||||||
|
"solveCount": 4,
|
||||||
|
"solvedByMe": false,
|
||||||
|
"solvers": [ /* SolverRow[] when ?include=solvers */ ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"solvedChallengeIds": ["<uuid>", "..."],
|
||||||
|
"perChallengeLivePoints": { "<uuid>": 480 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`livePoints` is computed by
|
||||||
|
`computeLivePoints(initialPoints, minimumPoints, decaySolves, solveCount)`
|
||||||
|
(`backend/src/modules/challenges/scoring.util.ts`):
|
||||||
|
|
||||||
|
```
|
||||||
|
if (currentSolveCount >= decaySolves) return minimum;
|
||||||
|
raw = initial - (initial - minimum) * (currentSolveCount / decaySolves);
|
||||||
|
return max(minimum, round(raw));
|
||||||
|
```
|
||||||
|
|
||||||
|
Columns are sorted alphabetically by `abbreviation`; cards within a
|
||||||
|
column are sorted by `difficulty` (`LOW < MEDIUM < HIGH`) then by name.
|
||||||
|
Only `enabled = 1` rows are returned. The query joins
|
||||||
|
`challenge` and `category` once and groups in memory.
|
||||||
|
|
||||||
|
# `GET /api/v1/challenges/status`
|
||||||
|
|
||||||
|
Authenticated event-window snapshot (the same `EventStatePayload`
|
||||||
|
returned by the legacy `/api/v1/event/status` public endpoint, but
|
||||||
|
gated by JWT and shaped to the canonical 4-state payload used by the
|
||||||
|
SPA). Built by
|
||||||
|
`backend/src/common/services/event-status.service.ts:getState()`.
|
||||||
|
|
||||||
|
`ChallengesPage` calls this once on init to populate the event state
|
||||||
|
before the SSE frame arrives, so the gate UI is never blank on slow
|
||||||
|
networks.
|
||||||
|
|
||||||
|
# `GET /api/v1/challenges/:id`
|
||||||
|
|
||||||
|
Single challenge detail with the full `SolverRow[]` for that
|
||||||
|
challenge. `:id` must be a UUID (validated by `ChallengeIdParamSchema`).
|
||||||
|
The response shape is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"challenge": { /* BoardCard */ },
|
||||||
|
"solvers": [ /* SolverRow[] sorted by solvedAtUtc ASC */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`404 NOT_FOUND` is returned for unknown ids and for disabled
|
||||||
|
challenges.
|
||||||
|
|
||||||
|
# `POST /api/v1/challenges/:id/solves`
|
||||||
|
|
||||||
|
Submit a flag. Body is validated by `SolveSubmitBodySchema`
|
||||||
|
(`{ flag: string, min 1, max 4096 }`). Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "solved" | "already_solved",
|
||||||
|
"challenge": { /* BoardCard with solvedByMe=true and the new solveCount */ },
|
||||||
|
"awarded": {
|
||||||
|
"position": 1,
|
||||||
|
"basePoints": 500,
|
||||||
|
"rankBonus": 15,
|
||||||
|
"awardedPoints": 515,
|
||||||
|
"awardedAtUtc": "2026-07-23T00:01:30.000Z"
|
||||||
|
},
|
||||||
|
"solvers": [ /* SolverRow[] */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The full transaction (`ChallengesService.submitFlag`):
|
||||||
|
|
||||||
|
1. Loads the challenge + category inside a `dataSource.transaction`.
|
||||||
|
2. Reads `EventStatusService.getState()`; rejects with
|
||||||
|
`409 EVENT_NOT_RUNNING` when `state !== 'running'`.
|
||||||
|
3. Looks for an existing `(challengeId, userId)` `solve` row.
|
||||||
|
* If found, returns `status: 'already_solved'` with the original
|
||||||
|
awarded points and the current solver list (no new row is
|
||||||
|
inserted; the unique index `uq_solve_challenge_user` would block
|
||||||
|
it anyway).
|
||||||
|
4. Constant-time compares the submitted flag against `challenge.flag`
|
||||||
|
via `safeEqualString`. On mismatch rejects with
|
||||||
|
`400 FLAG_INCORRECT`.
|
||||||
|
5. Counts existing solves (`position = count + 1`).
|
||||||
|
6. Computes `basePoints = computeLivePoints(...)` for `position - 1`,
|
||||||
|
`rankBonus = rankBonusForPosition(position)` (`15 / 10 / 5 / 0`),
|
||||||
|
`awardedPoints = basePoints + rankBonus`.
|
||||||
|
7. Inserts the new `solve` row with `is_first` / `is_second` /
|
||||||
|
`is_third` flags populated.
|
||||||
|
8. On `SQLITE_CONSTRAINT_UNIQUE` (race) falls back to the
|
||||||
|
`already_solved` shape.
|
||||||
|
9. On success, publishes a solve frame via
|
||||||
|
`SseHubService.emitScoreboard(...)` (see below) so every
|
||||||
|
`/api/v1/events` subscriber sees the live event.
|
||||||
|
|
||||||
|
The flag is **never** returned in any DTO exposed by this controller
|
||||||
|
(it is read only inside the `dataSource.transaction` and is not
|
||||||
|
included in `BoardCard`, `SolverRow`, `AwardedSolve`, or
|
||||||
|
`SolveResponse`).
|
||||||
|
|
||||||
|
# `SSE GET /api/v1/events`
|
||||||
|
|
||||||
|
Authenticated global SSE stream mounted by
|
||||||
|
`backend/src/modules/challenges/events.controller.ts`. It merges three
|
||||||
|
sources and emits `MessageEvent` frames whose `type` is one of:
|
||||||
|
|
||||||
|
| `event:` | Payload |
|
||||||
|
|----------|-------------------------------------------------------------------------------------------------|
|
||||||
|
| `status` | `{ state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }` |
|
||||||
|
| `solve` | `{ challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count, initial_points, minimum_points, decay_solves }` |
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
|
||||||
|
| Source | Trigger |
|
||||||
|
|-----------------------------------------|------------------------------------------------------------------------------------|
|
||||||
|
| `interval(60_000).pipe(startWith(0))` | Refresh the full `status` payload every 60 seconds so a disconnected client self-heals. |
|
||||||
|
| `SseHubService.event$()` (filter `'general'` topic) | Re-publish the current `status` whenever an admin updates general settings. |
|
||||||
|
| `SseHubService.event$()` (legacy event payloads) | Legacy fallback for pre-existing event-window broadcasts. |
|
||||||
|
| `SseHubService.scoreboard$()` | Every fresh `solve` published by `ChallengesService.submitFlag`. |
|
||||||
|
|
||||||
|
Consecutive identical `status` frames are suppressed by
|
||||||
|
`distinctUntilChanged(JSON.stringify)`. The `solve` frames are
|
||||||
|
appended as-is.
|
||||||
|
|
||||||
|
## Solve frame wire format
|
||||||
|
|
||||||
|
`SseHubService.emitScoreboard({...})` is called with a flat
|
||||||
|
`SolveEventPayload` (see
|
||||||
|
`backend/src/modules/challenges/dto/challenges.dto.ts`). The
|
||||||
|
controller maps both camelCase and snake_case aliases so a
|
||||||
|
downstream consumer can read either shape (for example
|
||||||
|
`challenge_id` or `challengeId`, `player_id` or `userId`,
|
||||||
|
`awarded_points` or `pointsAwarded`).
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
|
||||||
|
The challenges endpoints use three additional error codes from
|
||||||
|
`backend/src/common/errors/error-codes.ts`:
|
||||||
|
|
||||||
|
| Code | HTTP | When |
|
||||||
|
|---------------------|------|------------------------------------------------------------|
|
||||||
|
| `EVENT_NOT_RUNNING` | 409 | Flag submitted while the event is in `countdown` / `stopped` / `unconfigured`. |
|
||||||
|
| `FLAG_INCORRECT` | 400 | Submitted flag did not constant-time-equal `challenge.flag`. |
|
||||||
|
| `CHALLENGE_DISABLED`| — | Reserved for future use (admin-side disabled challenges already return `404 NOT_FOUND` from this controller). |
|
||||||
|
|
||||||
|
The full error envelope and middleware are documented in
|
||||||
|
[REST API Overview](/api/rest-overview.md).
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Challenge Tables](/database/challenges.md)
|
||||||
|
- [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`)
|
||||||
|
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||||
|
- [Challenges Board Guide](/guides/challenges-board.md)
|
||||||
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
@@ -3,7 +3,7 @@ type: api
|
|||||||
title: REST API Overview
|
title: REST API Overview
|
||||||
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
||||||
tags: [api, rest, overview, csrf]
|
tags: [api, rest, overview, csrf]
|
||||||
timestamp: 2026-07-21T22:19:08Z
|
timestamp: 2026-07-23T00:10:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Base URL & versioning
|
# Base URL & versioning
|
||||||
@@ -78,7 +78,18 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
|
|||||||
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
|
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
|
||||||
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
|
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
|
||||||
`REGISTRATIONS_DISABLED`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`,
|
`REGISTRATIONS_DISABLED`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`,
|
||||||
`PASSWORDS_DO_NOT_MATCH`, `INTERNAL`.
|
`PASSWORDS_DO_NOT_MATCH`, `CATEGORY_HAS_CHALLENGES`, `SYSTEM_PROTECTED`,
|
||||||
|
`CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`,
|
||||||
|
`CHALLENGE_INVALID_POINTS`, `CHALLENGE_INVALID_PORT`,
|
||||||
|
`CHALLENGE_INVALID_IP`, `CHALLENGE_FILE_LIMIT`,
|
||||||
|
`IMPORT_VALIDATION_FAILED`, `IMPORT_PARTIAL_FAILURE`,
|
||||||
|
`EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`, `INTERNAL`.
|
||||||
|
|
||||||
|
The player-facing codes (`EVENT_NOT_RUNNING`, `FLAG_INCORRECT`) are
|
||||||
|
emitted by the challenges submit endpoint; see
|
||||||
|
[Challenges Endpoints](/api/challenges.md) for the full set of HTTP
|
||||||
|
statuses and the canonical messages produced by
|
||||||
|
`messageForSolveError`.
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
@@ -86,4 +97,5 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
|
|||||||
- [Admin Endpoints](/api/admin.md)
|
- [Admin Endpoints](/api/admin.md)
|
||||||
- [Setup Endpoint](/api/setup.md)
|
- [Setup Endpoint](/api/setup.md)
|
||||||
- [System Endpoints](/api/system.md)
|
- [System Endpoints](/api/system.md)
|
||||||
- [Uploads Endpoints](/api/uploads.md)
|
- [Uploads Endpoints](/api/uploads.md)
|
||||||
|
- [Challenges Endpoints](/api/challenges.md)
|
||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Backend Module Map
|
title: Backend Module Map
|
||||||
description: NestJS modules, controllers, services, and how they are wired together.
|
description: NestJS modules, controllers, services, and how they are wired together.
|
||||||
tags: [architecture, backend, nestjs, modules]
|
tags: [architecture, backend, nestjs, modules]
|
||||||
timestamp: 2026-07-21T22:19:08Z
|
timestamp: 2026-07-23T00:10:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Module Map
|
# Module Map
|
||||||
@@ -30,6 +30,7 @@ also registers two global providers:
|
|||||||
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Four controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). |
|
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Four controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). |
|
||||||
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
|
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
|
||||||
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). |
|
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). |
|
||||||
|
| `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). |
|
||||||
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
|
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
|
||||||
|
|
||||||
# Controllers
|
# Controllers
|
||||||
@@ -46,6 +47,8 @@ also registers two global providers:
|
|||||||
| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` |
|
| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` |
|
||||||
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
|
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
|
||||||
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
|
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
|
||||||
|
| `ChallengesController` | `/api/v1/challenges` | Authenticated | `backend/src/modules/challenges/challenges.controller.ts` |
|
||||||
|
| `ChallengesEventsController` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` |
|
||||||
|
|
||||||
# Common services
|
# Common services
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ also registers two global providers:
|
|||||||
| `ThemeLoaderService` | `backend/src/common/utils/theme-loader.service.ts` | Loads + validates the 10 canonical themes. |
|
| `ThemeLoaderService` | `backend/src/common/utils/theme-loader.service.ts` | Loads + validates the 10 canonical themes. |
|
||||||
| `ZodValidationPipe` | `backend/src/common/pipes/zod-validation.pipe.ts` | Replaces `ValidationPipe` for zod-validated DTOs. |
|
| `ZodValidationPipe` | `backend/src/common/pipes/zod-validation.pipe.ts` | Replaces `ValidationPipe` for zod-validated DTOs. |
|
||||||
| `TransformInterceptor` | `backend/src/common/interceptors/transform.interceptor.ts` | Optional response transformer. |
|
| `TransformInterceptor` | `backend/src/common/interceptors/transform.interceptor.ts` | Optional response transformer. |
|
||||||
|
| `scoring.util` | `backend/src/modules/challenges/scoring.util.ts` | `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and `safeEqualString` (constant-time flag compare). |
|
||||||
|
|
||||||
# Decorators
|
# Decorators
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
type: architecture
|
type: architecture
|
||||||
title: Frontend Structure
|
title: Frontend Structure
|
||||||
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
|
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
|
||||||
tags: [architecture, frontend, angular, shell, sse]
|
tags: [architecture, frontend, angular, shell, sse, challenges, notifications]
|
||||||
timestamp: 2026-07-22T10:32:00Z
|
timestamp: 2026-07-23T00:10:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
@@ -15,7 +15,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
|||||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | First-admin creation flow. |
|
| `/bootstrap` | `SetupCreateAdminComponent` | — | First-admin creation flow. |
|
||||||
| `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. |
|
| `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. |
|
||||||
| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
|
| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
|
||||||
| `/challenges` | `ChallengesPage` | inherited | Placeholder challenges page. |
|
| `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). |
|
||||||
| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
|
| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
|
||||||
| `/blog` | `BlogPage` | inherited | Placeholder blog page. |
|
| `/blog` | `BlogPage` | inherited | Placeholder blog page. |
|
||||||
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
|
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
|
||||||
@@ -33,6 +33,12 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
|||||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback and exposes `clearAndBroadcast` / `onPeerInvalidation`. |
|
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback and exposes `clearAndBroadcast` / `onPeerInvalidation`. |
|
||||||
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. |
|
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. |
|
||||||
| `auth-session-events.pure.ts` | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and the namespaced channel / storage-key constants. |
|
| `auth-session-events.pure.ts` | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and the namespaced channel / storage-key constants. |
|
||||||
|
| `ChallengesStore` | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board payload, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. |
|
||||||
|
| `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. |
|
||||||
|
| `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). |
|
||||||
|
| `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. |
|
||||||
|
| `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
|
||||||
|
| `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that pushes a friendly message into `NotificationService` for every `HttpErrorResponse`; suppresses duplicate toasts for endpoints with their own error UI (login form, `/challenges/status` snapshot). |
|
||||||
|
|
||||||
# Authenticated SSE wiring
|
# Authenticated SSE wiring
|
||||||
|
|
||||||
@@ -64,8 +70,11 @@ on destruction.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `frontend/src/app/app.routes.ts` | Registers shell and child routes. |
|
| `frontend/src/app/app.routes.ts` | Registers shell and child routes. |
|
||||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Adds Bearer authentication to Angular HTTP requests. |
|
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Adds Bearer authentication to Angular HTTP requests. |
|
||||||
|
| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression rules for login + `/challenges/status`). |
|
||||||
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. |
|
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. |
|
||||||
| `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. |
|
| `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page; owns gate logic, modal lifecycle, SSE wiring, and the countdown-zero reload. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. |
|
||||||
| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
|
| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
|
||||||
|
|
||||||
# Event-status pure helpers
|
# Event-status pure helpers
|
||||||
@@ -111,4 +120,6 @@ for the full contract and tester matrix.
|
|||||||
|
|
||||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||||
- [Event Window](/guides/event-window.md)
|
- [Event Window](/guides/event-window.md)
|
||||||
|
- [Challenges Board](/guides/challenges-board.md)
|
||||||
|
- [Notifications](/guides/notifications.md)
|
||||||
- [System Overview](/architecture/overview.md)
|
- [System Overview](/architecture/overview.md)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
type: architecture
|
type: architecture
|
||||||
title: Key Files Index
|
title: Key Files Index
|
||||||
description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, and the challenges full-replace import flow.
|
description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, the challenges full-replace import flow, and the authenticated player-facing challenges board.
|
||||||
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import]
|
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications]
|
||||||
timestamp: 2026-07-22T22:17:11Z
|
timestamp: 2026-07-23T00:10:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
@@ -31,6 +31,13 @@ timestamp: 2026-07-22T22:17:11Z
|
|||||||
| `tests/backend/admin-general-event-window.spec.ts` | Focused contract tests for valid, empty, malformed, equal, and reversed event-window timestamps. |
|
| `tests/backend/admin-general-event-window.spec.ts` | Focused contract tests for valid, empty, malformed, equal, and reversed event-window timestamps. |
|
||||||
| `tests/backend/admin-general-service.spec.ts` | General-settings schema and service tests, including per-field empty datetime failures and settings-event emission. |
|
| `tests/backend/admin-general-service.spec.ts` | General-settings schema and service tests, including per-field empty datetime failures and settings-event emission. |
|
||||||
| `tests/backend/admin-challenges-import-replace.spec.ts` | Contract tests for the confirmed-import full-replace: wipes unrelated pre-existing challenges, unlinks wiped disk files, empty archive, unknown-category skip-then-wipe. |
|
| `tests/backend/admin-challenges-import-replace.spec.ts` | Contract tests for the confirmed-import full-replace: wipes unrelated pre-existing challenges, unlinks wiped disk files, empty archive, unknown-category skip-then-wipe. |
|
||||||
|
| `backend/src/modules/challenges/challenges.module.ts` | Wires `ChallengesController`, `ChallengesEventsController`, `ChallengesService`, and the four TypeORM entities (`ChallengeEntity`, `CategoryEntity`, `SolveEntity`, `UserEntity`). |
|
||||||
|
| `backend/src/modules/challenges/challenges.controller.ts` | Registers `/api/v1/challenges/{board,status,:id,:id/solves}` (all JWT-protected). |
|
||||||
|
| `backend/src/modules/challenges/events.controller.ts` | Authenticated SSE `/api/v1/events` merging status ticks + hub `general` pushes + `scoreboard$` solve frames. |
|
||||||
|
| `backend/src/modules/challenges/challenges.service.ts` | `getBoard` / `getDetail` / `submitFlag` + `dataSource.transaction` for race-safe idempotent solves; publishes `solve` SSE frames on success. |
|
||||||
|
| `backend/src/modules/challenges/dto/challenges.dto.ts` | zod contracts (`SolveSubmitBodySchema`, `BoardQuerySchema`, `ChallengeIdParamSchema`) + DTO types for the player-facing board. |
|
||||||
|
| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and constant-time `safeEqualString` for flag comparison. |
|
||||||
|
| `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). |
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
|
|
||||||
@@ -65,9 +72,28 @@ timestamp: 2026-07-22T22:17:11Z
|
|||||||
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
|
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
|
||||||
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
|
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
|
||||||
| `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. |
|
| `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the countdown-zero reload. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). |
|
||||||
|
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. |
|
||||||
|
| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders a single challenge card on the board (difficulty, live points, solve count, `✓` when solved by the player). |
|
||||||
|
| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Challenge detail modal: description, flag form, awarded banner, solvers list, live-solve updates. |
|
||||||
|
| `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
|
||||||
|
| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). |
|
||||||
|
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm`. |
|
||||||
|
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. |
|
||||||
|
| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. |
|
||||||
|
| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. |
|
||||||
|
| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
|
||||||
|
| `tests/backend/challenges-events-sse.spec.ts` | `/api/v1/events` SSE: status + solve frames, dedup, ordering. |
|
||||||
|
| `tests/backend/challenges-submit-flag.spec.ts` | Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. |
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||||
- [System Endpoints](/api/system.md)
|
- [System Endpoints](/api/system.md)
|
||||||
|
- [Challenges Endpoints](/api/challenges.md)
|
||||||
|
- [Challenges Board](/guides/challenges-board.md)
|
||||||
|
- [Notifications](/guides/notifications.md)
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Challenges Board
|
||||||
|
description: How a signed-in player navigates the `/challenges` page, opens a challenge modal, submits a flag, and watches live solve updates.
|
||||||
|
tags: [guide, challenges, board, flag, score, sse, tester]
|
||||||
|
timestamp: 2026-07-23T00:10:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# When this view is available
|
||||||
|
|
||||||
|
`/challenges` is rendered by `ChallengesPage`
|
||||||
|
(`frontend/src/app/features/challenges/challenges.page.ts`) inside the
|
||||||
|
authenticated shell. The page is gated by:
|
||||||
|
|
||||||
|
| Gate | Where |
|
||||||
|
|---------------------------------------|--------------------------------------------------|
|
||||||
|
| Signed in (access token + refresh) | `authGuard` in `app.routes.ts`. |
|
||||||
|
| Event window state | `ChallengesPage.isRunning()` derived from `EventStatusStore`. The board is only shown when `state === 'running'`; otherwise the page renders a gate panel (see below). |
|
||||||
|
|
||||||
|
The first time the page mounts it triggers `ChallengesStore.load()`
|
||||||
|
which calls `GET /api/v1/challenges/board`. It also calls
|
||||||
|
`GET /api/v1/challenges/status` once to populate the event state
|
||||||
|
synchronously, then opens the authenticated SSE stream on
|
||||||
|
`/api/v1/events` for live `solve` frames.
|
||||||
|
|
||||||
|
# How to access (tester steps)
|
||||||
|
|
||||||
|
1. Initialize the instance and sign in (`/login`).
|
||||||
|
2. Confirm the SPA routes to `/` and redirects to `/challenges`.
|
||||||
|
3. Configure an event window (`/admin/general`) whose
|
||||||
|
`eventStartUtc` is in the past and `eventEndUtc` is in the future
|
||||||
|
so the event state is `running`.
|
||||||
|
|
||||||
|
# Expected behavior
|
||||||
|
|
||||||
|
## Board grid (state = `running`)
|
||||||
|
|
||||||
|
The page renders an `<h2>Challenges</h2>` followed by a horizontal
|
||||||
|
flex container (`[data-testid="challenges-grid"]`) of one column per
|
||||||
|
category.
|
||||||
|
|
||||||
|
| Element | Selector | Expected |
|
||||||
|
|----------------------------------------|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
|
||||||
|
| Category column | `[data-testid="category-column-CR"]` (or any abbreviation) | Header shows the category `icon`, uppercase `abbreviation`, and full `name`; below it is a vertical list of cards. |
|
||||||
|
| Category column with no challenges | Same | The header renders but the body shows `No challenges yet.` |
|
||||||
|
| Challenge card | `[data-testid="challenge-card-<uuid>"]` | Click anywhere on the card to open the modal. |
|
||||||
|
| Difficulty pill | `.diff.diff-LOW` / `.diff-MEDIUM` / `.diff-HIGH` | Green / yellow / red pill matching the challenge difficulty. |
|
||||||
|
| Live points | `[data-testid="points-<uuid>"]` | `livePoints` from the board payload; updates live as `solve` frames arrive. |
|
||||||
|
| Solve count | text `N solve` / `N solves` | Number of distinct players who have solved this challenge. |
|
||||||
|
| Solved-by-me check | `.check` (the `✓` glyph) | Appears on the card once the player has solved it; the card border also flips to the success color. |
|
||||||
|
|
||||||
|
Columns are sorted alphabetically by `abbreviation`; cards within a
|
||||||
|
column are sorted `LOW → MEDIUM → HIGH` then by name (lowercased).
|
||||||
|
|
||||||
|
## Gate panel (state != `running`)
|
||||||
|
|
||||||
|
When the event is in any other state, the grid is hidden and the page
|
||||||
|
renders `[data-testid="challenges-gate"]`:
|
||||||
|
|
||||||
|
| Event state | Headline | Countdown text (`[data-testid="challenges-countdown"]`) | Helper label |
|
||||||
|
|---------------|---------------------------|--------------------------------------------------------|-----------------------------------------------------|
|
||||||
|
| `countdown` | `Event starts in` | `DD:HH:mm` until `eventStartUtc` | `The board will be available once the event is running.` |
|
||||||
|
| `running` | _(grid shown instead)_ | — | — |
|
||||||
|
| `stopped` | `Event ended` | `Event ended` | `The board is closed.` |
|
||||||
|
| `unconfigured`| `Awaiting configuration` | empty | _(none)_ |
|
||||||
|
|
||||||
|
When `secondsToStart` or `secondsToEnd` reach zero, the
|
||||||
|
`ChallengesPage` triggers a full `window.location.reload()` via
|
||||||
|
`EventStatusStore.reloadAtCountdownZero(...)` so the SPA re-evaluates
|
||||||
|
the board visibility from the server snapshot.
|
||||||
|
|
||||||
|
## Challenge modal
|
||||||
|
|
||||||
|
Clicking a card opens `ChallengeModalComponent`
|
||||||
|
(`[data-testid="challenge-modal-<uuid>"]`). The modal has a dark
|
||||||
|
backdrop; clicking the backdrop or pressing `Esc` closes it.
|
||||||
|
|
||||||
|
| Section | Selector / element | Expected |
|
||||||
|
|------------------------|----------------------------------------------------------|------------------------------------------------------------------------------------------|
|
||||||
|
| Header | `<h3>` + pills | Challenge name, category abbreviation pill, difficulty pill (`LOW`/`MEDIUM`/`HIGH`), live points pill, and `Close` button. |
|
||||||
|
| Solved banner | `[data-testid="modal-solved-banner"]` | Renders `You solved this challenge.` when `card.solvedByMe` is true. |
|
||||||
|
| Awarded banner | `[data-testid="modal-awarded-banner"]` | `Awarded X pts (base Y + rank bonus Z)` after a successful submit. |
|
||||||
|
| Notice banner | `.notice` | When the event is not running, shows `Submissions are only accepted while the event is running.` and disables the input. |
|
||||||
|
| Description | `[innerHTML]` from `MarkdownService.render(...)` | Markdown body, links, code, etc. rendered from `card.descriptionMd`. |
|
||||||
|
| Flag input | `[data-testid="flag-input"]` | Free text input; disabled while submitting or while the event is not running. |
|
||||||
|
| Solve button | `[data-testid="solve-button"]` | Disabled while submitting. Submits via `ChallengesStore.submit(...)`. |
|
||||||
|
| Inline error | `[data-testid="modal-error"]` | Renders the friendly message from `messageForSolveError(parseSolveError(...))`. |
|
||||||
|
| Solvers list | `[data-testid="modal-solvers"]` | Header `Solvers (N)` then a row per solver (`#position`, name, local-time, awarded points). Top three ranks are coloured gold / silver / bronze; subsequent ranks use the success color. |
|
||||||
|
| Empty solvers state | `<em>No solvers yet.</em>` | Shown when the challenge has no solves yet. |
|
||||||
|
|
||||||
|
## Submitting a flag
|
||||||
|
|
||||||
|
1. Type the flag into `[data-testid="flag-input"]`.
|
||||||
|
2. Click `[data-testid="solve-button"]` (or press Enter inside the
|
||||||
|
form).
|
||||||
|
3. The modal calls `ChallengesStore.submit(challengeId, flag)` which
|
||||||
|
POSTs `{ flag }` to `/api/v1/challenges/:id/solves`.
|
||||||
|
4. On `200 solved`:
|
||||||
|
* The flag input is cleared.
|
||||||
|
* The `Awarded X pts (base Y + rank bonus Z)` banner appears.
|
||||||
|
* The solvers list re-renders with the player now at position `N`.
|
||||||
|
* The corresponding card on the board gets the success border and
|
||||||
|
`✓`.
|
||||||
|
5. On `200 already_solved` (idempotent re-submit):
|
||||||
|
* Same banner + solvers list, but the inline error area shows
|
||||||
|
`You already solved this challenge.`
|
||||||
|
6. On `400 FLAG_INCORRECT`:
|
||||||
|
* Inline error: `Incorrect flag. Try again.`
|
||||||
|
* Flag input is re-enabled so the player can retry.
|
||||||
|
7. On `409 EVENT_NOT_RUNNING`:
|
||||||
|
* Inline error: `Submissions are only accepted while the event is running.`
|
||||||
|
* The notice banner also reflects the event state.
|
||||||
|
8. On any other error (network, 5xx):
|
||||||
|
* The `errorNotificationInterceptor` also pushes a top-level
|
||||||
|
notification in the SPA's notification store (see
|
||||||
|
[Notifications](/guides/notifications.md)).
|
||||||
|
|
||||||
|
## Live solve updates
|
||||||
|
|
||||||
|
While the modal is open, the page subscribes to live `solve` frames
|
||||||
|
for the selected challenge via
|
||||||
|
`ChallengesStore.registerSolveListener(id, payload => modal.applyLiveSolve(payload))`.
|
||||||
|
Each `solve` SSE frame:
|
||||||
|
|
||||||
|
* Updates `card.livePoints` and `card.solveCount` on the modal.
|
||||||
|
* Merges the new solver into the existing `solvers` array (sorted by
|
||||||
|
`solvedAtUtc` ASC) via `mergeSolveEventIntoSolvers`.
|
||||||
|
* Updates the matching board card (point value + solve count + the
|
||||||
|
`✓` if the player is the solver).
|
||||||
|
|
||||||
|
When the user closes the modal, the listener is unregistered; on
|
||||||
|
destruction of the page the SSE source is closed via
|
||||||
|
`ChallengesStore.stop()`.
|
||||||
|
|
||||||
|
# Tester flows
|
||||||
|
|
||||||
|
## Running event board
|
||||||
|
|
||||||
|
1. Configure the event window as running; confirm the board renders
|
||||||
|
the columns/cards described above.
|
||||||
|
2. Open and close a card — the modal should mount and unmount cleanly
|
||||||
|
with no console errors; the SSE source should remain connected.
|
||||||
|
3. Solve a flag and confirm the awarded banner, the new solver row,
|
||||||
|
the updated board card (`✓` and new live points), and the new
|
||||||
|
`livePoints` on the same card across all open clients.
|
||||||
|
|
||||||
|
## Countdown / stopped gate
|
||||||
|
|
||||||
|
1. Set `eventStartUtc` in the future; confirm the gate panel shows
|
||||||
|
`Event starts in` and the `DD:HH:mm` countdown.
|
||||||
|
2. Wait for the countdown to hit zero; the SPA reloads and now shows
|
||||||
|
the board.
|
||||||
|
3. Set `eventEndUtc` in the past; confirm the gate shows
|
||||||
|
`Event ended`.
|
||||||
|
4. Delete one of the event-window settings; confirm `Awaiting
|
||||||
|
configuration` and an empty countdown.
|
||||||
|
|
||||||
|
## Submission errors
|
||||||
|
|
||||||
|
1. Submit an empty string — the modal's local guard skips the call.
|
||||||
|
2. Submit a wrong flag — expect `Incorrect flag. Try again.`.
|
||||||
|
3. Submit a correct flag twice — second call returns `already_solved`
|
||||||
|
and shows both the awarded banner and the
|
||||||
|
`You already solved this challenge.` inline error.
|
||||||
|
4. Try to submit while the event is `stopped` — expect
|
||||||
|
`Submissions are only accepted while the event is running.` and a
|
||||||
|
matching notice banner.
|
||||||
|
|
||||||
|
# Architecture map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---------------------------------------------------------|------------------------------------------------------------------------------------------------|
|
||||||
|
| `frontend/src/app/features/challenges/challenges.page.ts` | Smart page; owns the modal lifecycle, gate logic, SSE wiring, and the countdown-zero reload. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, solve listeners, per-card mutation on submit + SSE solve. |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service: `getBoard`, `getDetail`, `getEventState`, `submit` (returns `ApiErrorEnvelope`). |
|
||||||
|
| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types (`BoardCard`, `SolverRow`, `SolveEventPayload`, etc.) + helpers (`sortColumnsByAbbrev`, `formatDdHhMm`, `parseSolveEvent`, `messageForSolveError`). |
|
||||||
|
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders one category column (header + cards). |
|
||||||
|
| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders one challenge card on the board. |
|
||||||
|
| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Renders the challenge detail modal, flag form, solvers list, and live-solve updates. |
|
||||||
|
| `frontend/src/app/core/services/event-status.store.ts` | Anchor-based clock + 4-state event store; reused here for the gate logic. |
|
||||||
|
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based SSE transport used to open `/api/v1/events`. |
|
||||||
|
| `frontend/src/app/core/services/notification.service.ts` | Signal-backed notification store used by `errorNotificationInterceptor`. |
|
||||||
|
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers (sorting, parsers, friendly error mapping). |
|
||||||
|
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, stop(). |
|
||||||
|
| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. |
|
||||||
|
| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, and `?include=solvers` flag. |
|
||||||
|
| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
|
||||||
|
| `tests/backend/challenges-events-sse.spec.ts` | `/api/v1/events` SSE: status + solve frames, dedup, ordering. |
|
||||||
|
| `tests/backend/challenges-submit-flag.spec.ts` | Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. |
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Challenges Endpoints](/api/challenges.md)
|
||||||
|
- [Challenge Tables](/database/challenges.md)
|
||||||
|
- [System Endpoints](/api/system.md) (legacy `/events/status`)
|
||||||
|
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||||
|
- [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport)
|
||||||
|
- [Notifications](/guides/notifications.md) (error interceptor + service)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Notifications
|
||||||
|
description: SPA-wide notification store + the global HTTP error interceptor that translates backend error codes and statuses into friendly user-facing messages.
|
||||||
|
tags: [guide, notifications, error, interceptor, toast, ux]
|
||||||
|
timestamp: 2026-07-23T00:10:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Components
|
||||||
|
|
||||||
|
| Symbol | Path | Responsibility |
|
||||||
|
|-------------------------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|
|
||||||
|
| `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
|
||||||
|
| `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that catches `HttpErrorResponse`, looks up the error code, and pushes a friendly message into `NotificationService`. |
|
||||||
|
| `NotificationService.messages` | signal (consumed by future toast UI) | Currently the messages signal is appended in-memory only; the list itself doubles as the integration point for a future toast/banner component. Each push also mirrors the message to the browser console (`console.error` for `error`, `console.info` for `info`). |
|
||||||
|
|
||||||
|
# Wiring
|
||||||
|
|
||||||
|
`frontend/src/main.ts` registers the interceptor **after** `authInterceptor` so
|
||||||
|
the auth header is already attached when the error interceptor sees
|
||||||
|
the request:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
provideHttpClient(
|
||||||
|
withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor]),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The interceptor:
|
||||||
|
|
||||||
|
1. Catches every `HttpErrorResponse` thrown from any HTTP call.
|
||||||
|
2. Maps the response to a friendly message using `friendlyMessage(status, err.error)`.
|
||||||
|
3. Suppresses notifications for endpoints that have their own
|
||||||
|
user-facing error presentation (see *Suppression rules* below).
|
||||||
|
4. Pushes the message via `NotificationService.error(...)`.
|
||||||
|
5. Re-throws the original error so the caller still receives the
|
||||||
|
`ApiErrorEnvelope` (e.g. `ChallengesApiService.submit` reads
|
||||||
|
`envelope.code` to render the modal's inline error).
|
||||||
|
|
||||||
|
# Friendly message mapping
|
||||||
|
|
||||||
|
`friendlyMessage(status, body)` resolves in this order:
|
||||||
|
|
||||||
|
| Source | Message |
|
||||||
|
|-----------------------------------------------------|------------------------------------------------------------------------------|
|
||||||
|
| `status === 0` (network / CORS / aborted) | `Network error. Please try again.` |
|
||||||
|
| `body.code` matches `FRIENDLY_MESSAGES` | The mapped friendly text (see table below). |
|
||||||
|
| `status === 401` or `403` | `Your session is no longer valid.` (unless suppressed). |
|
||||||
|
| `status === 404` | `Not found.` |
|
||||||
|
| `status >= 500` | `Server error. Please try again later.` |
|
||||||
|
| `body.message` (non-empty) | Falls back to that message. |
|
||||||
|
| otherwise | `Request failed (<status>).` |
|
||||||
|
|
||||||
|
`FRIENDLY_MESSAGES` lookup table:
|
||||||
|
|
||||||
|
| Error code | Friendly text |
|
||||||
|
|-----------------------|------------------------------------------------------------|
|
||||||
|
| `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.` |
|
||||||
|
|
||||||
|
# Suppression rules
|
||||||
|
|
||||||
|
Some errors are expected and have their own UI. To avoid double-toasting:
|
||||||
|
|
||||||
|
| URL pattern | Status | Why |
|
||||||
|
|---------------------------------------|--------|---------------------------------------------------------------------------------------------------|
|
||||||
|
| `/api/v1/auth/login` `/csrf` `/register` | `401` | A failed login or first-fetch of the CSRF cookie produces a `401`; the form already renders the inline error. |
|
||||||
|
| `/api/v1/challenges/status` | `401` | The challenges page uses the snapshot as a bootstrap hint only — the SSE stream (`/api/v1/events`) is the source of truth and will deliver the next frame. |
|
||||||
|
|
||||||
|
In both cases the interceptor still re-throws the error so callers can
|
||||||
|
react locally; only the notification push is skipped.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Challenges Board](/guides/challenges-board.md) (uses
|
||||||
|
`errorNotificationInterceptor` for non-flag submission failures)
|
||||||
|
- [Authenticated Shell](/guides/authenticated-shell.md) (cross-tab
|
||||||
|
invalidation propagates from a 401 SSE response via
|
||||||
|
`NotificationService` is **not** triggered — the shell uses its own
|
||||||
|
peer-invalidation channel)
|
||||||
|
- [REST API Overview](/api/rest-overview.md) (error envelope)
|
||||||
+10
-1
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
|||||||
controls.
|
controls.
|
||||||
|
|
||||||
The docs below are organized by purpose so agents can pull just the slice
|
The docs below are organized by purpose so agents can pull just the slice
|
||||||
they need. Last regenerated 2026-07-22T22:17:11Z.
|
they need. Last regenerated 2026-07-23T00:10:00Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
@@ -50,6 +50,9 @@ they need. Last regenerated 2026-07-22T22:17:11Z.
|
|||||||
* [Admin Endpoints](/api/admin.md) - Admin-only endpoints covering user
|
* [Admin Endpoints](/api/admin.md) - Admin-only endpoints covering user
|
||||||
management, general settings (`/api/v1/admin/general/*`), and
|
management, general settings (`/api/v1/admin/general/*`), and
|
||||||
categories (`/api/v1/admin/categories/*`).
|
categories (`/api/v1/admin/categories/*`).
|
||||||
|
* [Challenges Endpoints](/api/challenges.md) - Authenticated challenge board,
|
||||||
|
detail, flag submission, event-state snapshot, and the combined
|
||||||
|
`/api/v1/events` SSE stream.
|
||||||
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
|
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
|
||||||
streams, public event-window settings, and the authenticated
|
streams, public event-window settings, and the authenticated
|
||||||
`/events/status` SSE stream.
|
`/events/status` SSE stream.
|
||||||
@@ -80,6 +83,9 @@ they need. Last regenerated 2026-07-22T22:17:11Z.
|
|||||||
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
|
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
|
||||||
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
|
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
|
||||||
every signed-in page.
|
every signed-in page.
|
||||||
|
* [Challenges Board](/guides/challenges-board.md) - How a signed-in
|
||||||
|
player navigates `/challenges`, opens the modal, submits a flag, and
|
||||||
|
watches live solve updates.
|
||||||
* [Change Password](/guides/change-password.md) - How a signed-in user
|
* [Change Password](/guides/change-password.md) - How a signed-in user
|
||||||
changes their own password, including every error branch and the
|
changes their own password, including every error branch and the
|
||||||
refresh-token revocation behavior.
|
refresh-token revocation behavior.
|
||||||
@@ -103,3 +109,6 @@ they need. Last regenerated 2026-07-22T22:17:11Z.
|
|||||||
truth for the shell status dot's color in each `EventState`.
|
truth for the shell status dot's color in each `EventState`.
|
||||||
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
|
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
|
||||||
broadcast to clients.
|
broadcast to clients.
|
||||||
|
* [Notifications](/guides/notifications.md) - The root notification
|
||||||
|
store + global HTTP error interceptor that translate backend error
|
||||||
|
codes and statuses into friendly user-facing messages.
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -39,7 +39,12 @@ export class AuthenticatedEventSourceService {
|
|||||||
buffered = [];
|
buffered = [];
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
const me = new MessageEvent(m.event, { data: m.data });
|
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);
|
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;
|
if (dataLines.length === 0) return;
|
||||||
const payload = dataLines.join('\n');
|
const payload = dataLines.join('\n');
|
||||||
if (listeners.has('message')) {
|
if (listeners.size > 0) {
|
||||||
dispatch('message', new MessageEvent(event, { data: payload }));
|
const me = new MessageEvent(event, { data: payload });
|
||||||
|
dispatch('message', me);
|
||||||
|
if (event && event !== 'message' && listeners.has(event)) {
|
||||||
|
dispatch(event, me);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
buffered.push({ event, data: payload });
|
buffered.push({ event, data: payload });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface EventStatePayload {
|
|||||||
|
|
||||||
export interface EventSourceLike {
|
export interface EventSourceLike {
|
||||||
addEventListener(
|
addEventListener(
|
||||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
type: 'open' | 'message' | 'error' | 'unauthorized' | string,
|
||||||
listener: (ev: MessageEvent | Event) => void,
|
listener: (ev: MessageEvent | Event) => void,
|
||||||
): void;
|
): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
|
|||||||
@@ -26,14 +26,28 @@ export class EventStatusStore {
|
|||||||
readonly serverNowUtc = signal<string | null>(null);
|
readonly serverNowUtc = signal<string | null>(null);
|
||||||
readonly eventStartUtc = signal<string | null>(null);
|
readonly eventStartUtc = signal<string | null>(null);
|
||||||
readonly eventEndUtc = signal<string | null>(null);
|
readonly eventEndUtc = signal<string | null>(null);
|
||||||
private readonly anchorDeltaMs = signal<number>(0);
|
private readonly clientAnchorMs = signal<number>(0);
|
||||||
private readonly tick = signal<number>(0);
|
private readonly tick = signal<number>(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<string>(() => {
|
||||||
|
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<number | null>(() => {
|
private readonly secondsToStart = computed<number | null>(() => {
|
||||||
void this.tick();
|
void this.tick();
|
||||||
const s = this.eventStartUtc();
|
const s = this.eventStartUtc();
|
||||||
if (!s) return null;
|
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);
|
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||||
return diff > 0 ? diff : 0;
|
return diff > 0 ? diff : 0;
|
||||||
});
|
});
|
||||||
@@ -42,7 +56,7 @@ export class EventStatusStore {
|
|||||||
void this.tick();
|
void this.tick();
|
||||||
const s = this.eventEndUtc();
|
const s = this.eventEndUtc();
|
||||||
if (!s) return null;
|
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);
|
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||||
return diff > 0 ? diff : 0;
|
return diff > 0 ? diff : 0;
|
||||||
});
|
});
|
||||||
@@ -51,8 +65,14 @@ export class EventStatusStore {
|
|||||||
return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd());
|
return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly secondsToStartPublic = computed<number | null>(() => this.secondsToStart());
|
||||||
|
readonly secondsToEndPublic = computed<number | null>(() => this.secondsToEnd());
|
||||||
|
|
||||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
private source: EventSourceLike | null = null;
|
private source: EventSourceLike | null = null;
|
||||||
|
private zeroWatcher: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private reloadOnZero: (() => void) | null = null;
|
||||||
|
private reloadOnZeroFired = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.destroyRef.onDestroy(() => this.stop());
|
this.destroyRef.onDestroy(() => this.stop());
|
||||||
@@ -63,14 +83,18 @@ export class EventStatusStore {
|
|||||||
this.serverNowUtc.set(payload.serverNowUtc);
|
this.serverNowUtc.set(payload.serverNowUtc);
|
||||||
this.eventStartUtc.set(payload.eventStartUtc);
|
this.eventStartUtc.set(payload.eventStartUtc);
|
||||||
this.eventEndUtc.set(payload.eventEndUtc);
|
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 {
|
start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
const src = createSource();
|
const src = createSource();
|
||||||
this.source = src;
|
this.source = src;
|
||||||
src.addEventListener('message', (ev) => {
|
const handler = (ev: MessageEvent | Event) => {
|
||||||
const me = ev as MessageEvent;
|
const me = ev as MessageEvent;
|
||||||
try {
|
try {
|
||||||
const data = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
|
const data = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
|
||||||
@@ -80,7 +104,9 @@ export class EventStatusStore {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore malformed frame
|
// ignore malformed frame
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
src.addEventListener('message', handler);
|
||||||
|
src.addEventListener('status', handler);
|
||||||
if (onUnauthorized) {
|
if (onUnauthorized) {
|
||||||
src.addEventListener('unauthorized', () => {
|
src.addEventListener('unauthorized', () => {
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +119,32 @@ export class EventStatusStore {
|
|||||||
if (!this.intervalId) {
|
if (!this.intervalId) {
|
||||||
this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000);
|
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 {
|
stop(): void {
|
||||||
@@ -108,5 +160,9 @@ export class EventStatusStore {
|
|||||||
clearInterval(this.intervalId);
|
clearInterval(this.intervalId);
|
||||||
this.intervalId = null;
|
this.intervalId = null;
|
||||||
}
|
}
|
||||||
|
if (this.zeroWatcher) {
|
||||||
|
clearInterval(this.zeroWatcher);
|
||||||
|
this.zeroWatcher = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<NotificationMessage[]>([]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: `
|
||||||
|
<section class="col" [attr.data-testid]="'category-column-' + column.abbreviation">
|
||||||
|
<header class="head">
|
||||||
|
<img *ngIf="column.iconPath" class="icon" [src]="column.iconPath" [alt]="column.abbreviation" />
|
||||||
|
<div>
|
||||||
|
<div class="abbr">{{ column.abbreviation }}</div>
|
||||||
|
<div class="name">{{ column.name }}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="cards">
|
||||||
|
<app-challenge-card
|
||||||
|
*ngFor="let card of column.cards; trackBy: trackByCard"
|
||||||
|
[card]="card"
|
||||||
|
(open)="openCard.emit($event)"
|
||||||
|
></app-challenge-card>
|
||||||
|
<p *ngIf="!column.cards.length" class="name">No challenges yet.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CategoryColumnComponent {
|
||||||
|
@Input({ required: true }) column!: CategoryColumn;
|
||||||
|
@Output() openCard = new EventEmitter<string>();
|
||||||
|
|
||||||
|
trackByCard = (_idx: number, c: { id: string }) => c.id;
|
||||||
|
}
|
||||||
@@ -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: `
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
[class.solved]="card.solvedByMe"
|
||||||
|
[attr.data-testid]="'challenge-card-' + card.id"
|
||||||
|
(click)="open.emit(card.id)"
|
||||||
|
>
|
||||||
|
<div class="card-head">
|
||||||
|
<img *ngIf="card.categoryIconPath" class="icon" [src]="card.categoryIconPath" [alt]="card.categoryAbbreviation" />
|
||||||
|
<span class="name">{{ card.name }}</span>
|
||||||
|
<span *ngIf="card.solvedByMe" class="check" aria-label="Solved">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="diff" [class]="'diff-' + card.difficulty">{{ card.difficulty }}</span>
|
||||||
|
<span class="points" [attr.data-testid]="'points-' + card.id">{{ card.livePoints }} pts</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{{ card.solveCount }} solve<span *ngIf="card.solveCount !== 1">s</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ChallengeCardComponent {
|
||||||
|
@Input({ required: true }) card!: BoardCard;
|
||||||
|
@Output() open = new EventEmitter<string>();
|
||||||
|
}
|
||||||
@@ -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: `
|
||||||
|
<div class="backdrop" (click)="onBackdrop($event)">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" [attr.data-testid]="'challenge-modal-' + (card?.id ?? '')">
|
||||||
|
<div class="header" *ngIf="card">
|
||||||
|
<h3>{{ card.name }}</h3>
|
||||||
|
<span class="pill">{{ card.categoryAbbreviation }}</span>
|
||||||
|
<span class="pill" [class]="'pill diff-' + card.difficulty">{{ card.difficulty }}</span>
|
||||||
|
<span class="pill" data-testid="modal-points">{{ card.livePoints }} pts</span>
|
||||||
|
<button type="button" class="close" (click)="close.emit()" aria-label="Close">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="solved-banner" *ngIf="card?.solvedByMe" data-testid="modal-solved-banner">
|
||||||
|
You solved this challenge.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="awarded-banner" *ngIf="awarded() as a" data-testid="modal-awarded-banner">
|
||||||
|
Awarded {{ a.awardedPoints }} pts
|
||||||
|
<span *ngIf="a.rankBonus > 0">(base {{ a.basePoints }} + rank bonus {{ a.rankBonus }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice" *ngIf="notice() as n">{{ n }}</div>
|
||||||
|
|
||||||
|
<div class="body" [innerHTML]="renderedDescription()"></div>
|
||||||
|
|
||||||
|
<form class="form" (ngSubmit)="onSubmit()" *ngIf="card">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="flagValue"
|
||||||
|
name="flag"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="flag{...}"
|
||||||
|
[disabled]="submitting() || !canSubmit"
|
||||||
|
data-testid="flag-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="submitting() || !canSubmit"
|
||||||
|
data-testid="solve-button"
|
||||||
|
>Solve</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="error" *ngIf="errorMessage() as e" data-testid="modal-error">{{ e }}</div>
|
||||||
|
|
||||||
|
<h4>Solvers ({{ solvers().length }})</h4>
|
||||||
|
<ul class="solvers" data-testid="modal-solvers">
|
||||||
|
<li *ngFor="let s of solvers(); trackBy: trackBySolver">
|
||||||
|
<span [class]="rankClass(s.position)">#{{ s.position }}</span>
|
||||||
|
<span>{{ s.playerName || ('Player ' + s.playerId.slice(0, 6)) }}</span>
|
||||||
|
<span>{{ formatLocal(s.solvedAtUtc) }}</span>
|
||||||
|
<span class="points">{{ s.awardedPoints }} pts</span>
|
||||||
|
</li>
|
||||||
|
<li *ngIf="!solvers().length"><em>No solvers yet.</em></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
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<void>();
|
||||||
|
@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<string | null>(null);
|
||||||
|
readonly awarded = signal<{ basePoints: number; rankBonus: number; awardedPoints: number; awardedAtUtc: string } | null>(null);
|
||||||
|
readonly notice = signal<string | null>(null);
|
||||||
|
|
||||||
|
private _solvers = signal<SolverRow[]>([]);
|
||||||
|
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<void> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
@Component({
|
||||||
selector: 'app-challenges-page',
|
selector: 'app-challenges-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
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: `
|
template: `
|
||||||
<section class="page-challenges">
|
<section class="wrap" data-testid="challenges-page">
|
||||||
<h2>Challenges</h2>
|
<ng-container *ngIf="isRunning(); else gate">
|
||||||
<p>Challenge list will appear here.</p>
|
<h2>Challenges</h2>
|
||||||
|
<div class="grid" data-testid="challenges-grid">
|
||||||
|
<app-category-column
|
||||||
|
*ngFor="let col of store.sortedColumns(); trackBy: trackByCol"
|
||||||
|
[column]="col"
|
||||||
|
(openCard)="openChallenge($event)"
|
||||||
|
></app-category-column>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #gate>
|
||||||
|
<div class="gate" data-testid="challenges-gate">
|
||||||
|
<h2 *ngIf="eventState() === 'countdown'">Event starts in</h2>
|
||||||
|
<h2 *ngIf="eventState() === 'stopped'">Event ended</h2>
|
||||||
|
<h2 *ngIf="eventState() === 'unconfigured'">Awaiting configuration</h2>
|
||||||
|
<div class="countdown" data-testid="challenges-countdown">
|
||||||
|
{{ countdownText() }}
|
||||||
|
</div>
|
||||||
|
<div class="label" *ngIf="eventState() === 'countdown'">The board will be available once the event is running.</div>
|
||||||
|
<div class="label" *ngIf="eventState() === 'stopped'">The board is closed.</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div class="error" *ngIf="store.error() as e">{{ e }}</div>
|
||||||
|
|
||||||
|
<app-challenge-modal
|
||||||
|
*ngIf="selectedCard() as card"
|
||||||
|
#modal
|
||||||
|
[card]="card"
|
||||||
|
[initialSolvers]="selectedSolvers()"
|
||||||
|
[canSubmit]="canSubmit()"
|
||||||
|
[noticeOverride]="modalNotice()"
|
||||||
|
(close)="closeChallenge()"
|
||||||
|
(submitted)="onSubmitted()"
|
||||||
|
></app-challenge-modal>
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
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<ReturnType<ChallengesStore['findCard']>>(null);
|
||||||
|
readonly selectedSolvers = signal<SolverRow[]>([]);
|
||||||
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<Difficulty, number>> = {
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
@@ -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<BoardResponse> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (opts?.includeSolvers) params['include'] = 'solvers';
|
||||||
|
return this.http
|
||||||
|
.get<BoardResponse>('/api/v1/challenges/board', { withCredentials: true, params })
|
||||||
|
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDetail(challengeId: string): Observable<ChallengeDetail> {
|
||||||
|
return this.http
|
||||||
|
.get<ChallengeDetail>(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventState(): Observable<EventStatePayloadLike> {
|
||||||
|
return this.http
|
||||||
|
.get<EventStatePayloadLike>('/api/v1/challenges/status', { withCredentials: true })
|
||||||
|
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(challengeId: string, flag: string): Observable<SolveResponse> {
|
||||||
|
return this.http
|
||||||
|
.post<SolveResponse>(
|
||||||
|
`/api/v1/challenges/${encodeURIComponent(challengeId)}/solves`,
|
||||||
|
{ flag },
|
||||||
|
{ withCredentials: true },
|
||||||
|
)
|
||||||
|
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BoardResponse | null>(null);
|
||||||
|
private readonly _eventState = signal<EventStatePayloadLike['state']>('unconfigured');
|
||||||
|
private readonly _secondsToStart = signal<number | null>(null);
|
||||||
|
private readonly _secondsToEnd = signal<number | null>(null);
|
||||||
|
private readonly _loading = signal(false);
|
||||||
|
private readonly _error = signal<string | null>(null);
|
||||||
|
private readonly _tick = signal(0);
|
||||||
|
private readonly _myUserId = signal<string | null>(null);
|
||||||
|
private readonly _selectedDetail = signal<ChallengeDetail | null>(null);
|
||||||
|
|
||||||
|
private readonly solveListeners = new Map<string, Set<(p: SolveEventLivePayload) => 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<CategoryColumn[]>(() => {
|
||||||
|
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<number | null>(() => {
|
||||||
|
void this._tick();
|
||||||
|
const state = this._eventState();
|
||||||
|
if (state === 'countdown') return this._secondsToStart();
|
||||||
|
if (state === 'running') return this._secondsToEnd();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private sse: EventSourceLike | null = null;
|
||||||
|
private countdownZeroHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
setMyUserId(id: string | null): void {
|
||||||
|
this._myUserId.set(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this._loading.set(true);
|
||||||
|
this._error.set(null);
|
||||||
|
try {
|
||||||
|
const board = await new Promise<BoardResponse>((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<EventStatePayloadLike> {
|
||||||
|
return this.api.getEventState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDetail(id: string): Promise<ChallengeDetail | null> {
|
||||||
|
try {
|
||||||
|
const detail = await new Promise<ChallengeDetail>((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<SolveResponse> {
|
||||||
|
return new Promise<SolveResponse>((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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,13 @@ import { AppComponent } from './app/app.component';
|
|||||||
import { APP_ROUTES } from './app/app.routes';
|
import { APP_ROUTES } from './app/app.routes';
|
||||||
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
||||||
import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor';
|
import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor';
|
||||||
|
import { errorNotificationInterceptor } from './app/core/interceptors/error-notification.interceptor';
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, {
|
bootstrapApplication(AppComponent, {
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])),
|
provideHttpClient(
|
||||||
|
withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor]),
|
||||||
|
),
|
||||||
provideRouter(APP_ROUTES, withComponentInputBinding()),
|
provideRouter(APP_ROUTES, withComponentInputBinding()),
|
||||||
],
|
],
|
||||||
}).catch((err) => console.error(err));
|
}).catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-board-test';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { CookieAccessInfo } from 'cookiejar';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
||||||
|
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||||
|
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||||
|
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||||
|
|
||||||
|
async function setRunningWindow(svc: SettingsService): Promise<void> {
|
||||||
|
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString());
|
||||||
|
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/v1/challenges/board (authenticated)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let adminToken: string;
|
||||||
|
let adminCsrf: string;
|
||||||
|
let settings: SettingsService;
|
||||||
|
let challengeRepo: Repository<ChallengeEntity>;
|
||||||
|
let categoryRepo: Repository<CategoryEntity>;
|
||||||
|
let cry: CategoryEntity;
|
||||||
|
let web: CategoryEntity;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
|
||||||
|
settings = app.get(SettingsService);
|
||||||
|
challengeRepo = app.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||||
|
categoryRepo = app.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||||
|
|
||||||
|
cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||||
|
web = (await categoryRepo.findOne({ where: { systemKey: 'WEB' } }))!;
|
||||||
|
if (!cry || !web) throw new Error('expected CRY and WEB seeded categories');
|
||||||
|
|
||||||
|
// Seed: 2 challenges under CRY (LOW, HIGH) and 1 under WEB (MEDIUM); one disabled.
|
||||||
|
await challengeRepo.save([
|
||||||
|
challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'alphacipher',
|
||||||
|
descriptionMd: '# Alpha\n',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'LOW',
|
||||||
|
initialPoints: 200,
|
||||||
|
minimumPoints: 100,
|
||||||
|
decaySolves: 5,
|
||||||
|
flag: 'flag{alpha}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Zeta-key',
|
||||||
|
descriptionMd: '# Zeta\n',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'HIGH',
|
||||||
|
initialPoints: 500,
|
||||||
|
minimumPoints: 100,
|
||||||
|
decaySolves: 10,
|
||||||
|
flag: 'flag{zeta}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'webmid',
|
||||||
|
descriptionMd: '# web\n',
|
||||||
|
categoryId: web.id,
|
||||||
|
difficulty: 'MEDIUM',
|
||||||
|
initialPoints: 300,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
flag: 'flag{web}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'hidden',
|
||||||
|
descriptionMd: 'hidden',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'LOW',
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
flag: 'flag{hidden}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await setRunningWindow(settings);
|
||||||
|
|
||||||
|
// Bootstrap admin and login.
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||||
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
|
const login = await request(server).post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${adminCsrf}`)
|
||||||
|
.set('X-CSRF-Token', adminCsrf)
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
adminToken = login.body.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthenticated requests with 401', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/api/v1/challenges/board').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns columns sorted by abbreviation with cards sorted by difficulty then name', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/board')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(Array.isArray(res.body.columns)).toBe(true);
|
||||||
|
const abbrs = res.body.columns.map((c: any) => c.abbreviation);
|
||||||
|
expect(abbrs).toEqual([...abbrs].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())));
|
||||||
|
|
||||||
|
const cryCol = res.body.columns.find((c: any) => c.abbreviation === 'CRY');
|
||||||
|
expect(cryCol).toBeDefined();
|
||||||
|
const difficulties = cryCol.cards.map((card: any) => card.difficulty);
|
||||||
|
expect(difficulties).toEqual(['LOW', 'HIGH']);
|
||||||
|
const enabledCardIds = res.body.columns.flatMap((c: any) => c.cards.map((card: any) => card.id));
|
||||||
|
// hidden should be excluded
|
||||||
|
expect(enabledCardIds).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the flag from every response (no plaintext leak)', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/board')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const json = JSON.stringify(res.body);
|
||||||
|
expect(json).not.toContain('flag{alpha}');
|
||||||
|
expect(json).not.toContain('flag{zeta}');
|
||||||
|
expect(json).not.toContain('flag{web}');
|
||||||
|
expect(json).not.toContain('flag{hidden}');
|
||||||
|
expect(json).not.toContain('"flag"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits per-card solvers by default (keeps payload small)', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/board')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const allCards: any[] = res.body.columns.flatMap((c: any) => c.cards);
|
||||||
|
for (const card of allCards) {
|
||||||
|
expect(card.solvers).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/challenges/:id returns detail with solvers + no flag leak', async () => {
|
||||||
|
const list = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/board')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const firstCardId: string = list.body.columns[0].cards[0].id;
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/challenges/${firstCardId}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.challenge.id).toBe(firstCardId);
|
||||||
|
expect(Array.isArray(res.body.solvers)).toBe(true);
|
||||||
|
const json = JSON.stringify(res.body);
|
||||||
|
expect(json).not.toContain('"flag"');
|
||||||
|
expect(json).not.toContain('flag{');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('?include=solvers attaches per-card solvers sorted by solve time', async () => {
|
||||||
|
const list = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/board')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const cardId: string = list.body.columns[0].cards[0].id;
|
||||||
|
// Solve it via the submit endpoint to populate solvers.
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/challenges/${cardId}/solves`)
|
||||||
|
.set('Cookie', `csrf=${adminCsrf}`)
|
||||||
|
.set('X-CSRF-Token', adminCsrf)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ flag: list.body.columns[0].cards[0].name === 'alphacipher' ? 'flag{alpha}' : 'flag{zeta}' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/board?include=solvers')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const cardWithSolvers: any = res.body.columns
|
||||||
|
.flatMap((c: any) => c.cards)
|
||||||
|
.find((card: any) => card.id === cardId);
|
||||||
|
expect(cardWithSolvers.solvers).toBeDefined();
|
||||||
|
expect(Array.isArray(cardWithSolvers.solvers)).toBe(true);
|
||||||
|
expect(cardWithSolvers.solvers[0]).toHaveProperty('position');
|
||||||
|
expect(cardWithSolvers.solvers[0]).toHaveProperty('playerName');
|
||||||
|
expect(cardWithSolvers.solvers[0]).toHaveProperty('awardedPoints');
|
||||||
|
const json = JSON.stringify(res.body);
|
||||||
|
expect(json).not.toContain('"flag"');
|
||||||
|
expect(json).not.toContain('flag{');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-events-sse-test';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { CookieAccessInfo } from 'cookiejar';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
import { SseHubService } from '../../backend/src/common/services/sse-hub.service';
|
||||||
|
|
||||||
|
describe('GET /api/v1/events (authenticated SSE)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let adminToken: string;
|
||||||
|
let hub: SseHubService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
hub = app.get(SseHubService);
|
||||||
|
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||||
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
const csrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
|
const login = await request(server).post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${csrf}`)
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
adminToken = login.body.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthenticated SSE with 401', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/api/v1/events').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a status frame with state + timestamps and a solve frame when the hub emits one', (done) => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const req = request(server)
|
||||||
|
.get('/api/v1/events')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.set('Accept', 'text/event-stream');
|
||||||
|
let gotStatus = false;
|
||||||
|
let gotSolve = false;
|
||||||
|
req
|
||||||
|
.buffer(true)
|
||||||
|
.parse((res, cb) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (!gotStatus) {
|
||||||
|
// Status frame: `event: status\ndata: {...}\n\n` with snake_case keys.
|
||||||
|
const m = text.match(/(?:event: status\n)?data: (\{[^\n]*\})\n/);
|
||||||
|
if (m) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(m[1]);
|
||||||
|
expect(payload).toHaveProperty('state');
|
||||||
|
expect(payload).toHaveProperty('server_time_utc');
|
||||||
|
expect(payload).toHaveProperty('event_start_utc');
|
||||||
|
expect(payload).toHaveProperty('event_end_utc');
|
||||||
|
expect(payload).toHaveProperty('seconds_to_start');
|
||||||
|
expect(payload).toHaveProperty('seconds_to_end');
|
||||||
|
gotStatus = true;
|
||||||
|
} catch (e) {
|
||||||
|
(res as any).destroy();
|
||||||
|
done(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gotStatus && !gotSolve) {
|
||||||
|
hub.emitScoreboard({
|
||||||
|
topic: 'solve',
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'player1',
|
||||||
|
awardedPoints: 50,
|
||||||
|
rankBonus: 0,
|
||||||
|
pointsAwarded: 50,
|
||||||
|
solvedAt: new Date().toISOString(),
|
||||||
|
awardedAtUtc: new Date().toISOString(),
|
||||||
|
position: 1,
|
||||||
|
livePoints: 50,
|
||||||
|
solveCount: 1,
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
});
|
||||||
|
const m2 = text.match(/(?:event: solve\n)?data: (\{[^\n]*\})\n/g);
|
||||||
|
if (m2) {
|
||||||
|
for (const line of m2) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(line.replace(/^data: /, '').trim());
|
||||||
|
if (payload.challenge_id === 'c1') {
|
||||||
|
expect(payload.player_id).toBe('p1');
|
||||||
|
expect(payload.player_name).toBe('player1');
|
||||||
|
expect(payload.awarded_points).toBe(50);
|
||||||
|
expect(payload.rank_bonus).toBe(0);
|
||||||
|
expect(payload.awarded_at_utc).toBeTruthy();
|
||||||
|
expect(payload.position).toBe(1);
|
||||||
|
expect(payload.live_points).toBe(50);
|
||||||
|
expect(payload.solve_count).toBe(1);
|
||||||
|
expect(payload.initial_points).toBe(100);
|
||||||
|
expect(payload.minimum_points).toBe(50);
|
||||||
|
expect(payload.decay_solves).toBe(5);
|
||||||
|
gotSolve = true;
|
||||||
|
(res as any).destroy();
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||||
|
res.on('error', (err) => cb(err, null));
|
||||||
|
});
|
||||||
|
req.on('response', (res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
done(new Error(`Unexpected status ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', (err) => done(err));
|
||||||
|
req.end(() => {});
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-status-rest-test';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||||
|
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||||
|
|
||||||
|
describe('GET /api/v1/challenges/status (REST snapshot)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let settings: SettingsService;
|
||||||
|
let adminToken: string;
|
||||||
|
let adminCsrf: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
settings = app.get(SettingsService);
|
||||||
|
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||||
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
|
const login = await request(server).post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${adminCsrf}`)
|
||||||
|
.set('X-CSRF-Token', adminCsrf)
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
adminToken = login.body.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthenticated requests with 401', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/api/v1/challenges/status').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the running window snapshot for an authenticated user', async () => {
|
||||||
|
const start = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
const end = new Date(Date.now() + 3600_000).toISOString();
|
||||||
|
await settings.set(SETTINGS_KEYS.EVENT_START_UTC, start);
|
||||||
|
await settings.set(SETTINGS_KEYS.EVENT_END_UTC, end);
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/status')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.state).toBe('running');
|
||||||
|
expect(typeof res.body.serverNowUtc).toBe('string');
|
||||||
|
expect(res.body.eventStartUtc).toBe(start);
|
||||||
|
expect(res.body.eventEndUtc).toBe(end);
|
||||||
|
expect(res.body.secondsToStart).toBeNull();
|
||||||
|
expect(res.body.secondsToEnd).toBeGreaterThan(3500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns countdown state when the event has not started yet', async () => {
|
||||||
|
const start = new Date(Date.now() + 3600_000).toISOString();
|
||||||
|
const end = new Date(Date.now() + 7200_000).toISOString();
|
||||||
|
await settings.set(SETTINGS_KEYS.EVENT_START_UTC, start);
|
||||||
|
await settings.set(SETTINGS_KEYS.EVENT_END_UTC, end);
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/challenges/status')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(res.body.state).toBe('countdown');
|
||||||
|
expect(res.body.secondsToStart).toBeGreaterThan(3500);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-submit-test';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { CookieAccessInfo } from 'cookiejar';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
||||||
|
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||||
|
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
|
||||||
|
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||||
|
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||||
|
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||||
|
|
||||||
|
async function setRunningWindow(svc: SettingsService): Promise<void> {
|
||||||
|
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString());
|
||||||
|
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStoppedWindow(svc: SettingsService): Promise<void> {
|
||||||
|
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 7 * 24 * 3600_000).toISOString());
|
||||||
|
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() - 24 * 3600_000).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/v1/challenges/:id/solves', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let adminToken: string;
|
||||||
|
let adminCsrf: string;
|
||||||
|
let playerAgent: any;
|
||||||
|
let playerToken: string;
|
||||||
|
let playerCsrf: string;
|
||||||
|
let player2Agent: any;
|
||||||
|
let player2Token: string;
|
||||||
|
let player2Csrf: string;
|
||||||
|
let settings: SettingsService;
|
||||||
|
let challengeRepo: Repository<ChallengeEntity>;
|
||||||
|
let solveRepo: Repository<SolveEntity>;
|
||||||
|
let userRepo: Repository<UserEntity>;
|
||||||
|
let categoryRepo: Repository<CategoryEntity>;
|
||||||
|
let challenge: ChallengeEntity;
|
||||||
|
let challenge2: ChallengeEntity;
|
||||||
|
let playerId: string;
|
||||||
|
let player2Id: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
|
||||||
|
settings = app.get(SettingsService);
|
||||||
|
challengeRepo = app.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||||
|
solveRepo = app.get<Repository<SolveEntity>>(getRepositoryToken(SolveEntity));
|
||||||
|
userRepo = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
|
||||||
|
categoryRepo = app.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||||
|
|
||||||
|
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||||
|
if (!cry) throw new Error('expected CRY seeded category');
|
||||||
|
|
||||||
|
challenge = await challengeRepo.save(
|
||||||
|
challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'alphacipher',
|
||||||
|
descriptionMd: '# Alpha\n',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'LOW',
|
||||||
|
initialPoints: 500,
|
||||||
|
minimumPoints: 100,
|
||||||
|
decaySolves: 2,
|
||||||
|
flag: 'flag{alpha}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
challenge2 = await challengeRepo.save(
|
||||||
|
challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'disabled-chal',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'LOW',
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
flag: 'flag{x}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await setRunningWindow(settings);
|
||||||
|
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||||
|
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||||
|
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||||
|
const adminLogin = await request(server).post('/api/v1/auth/login')
|
||||||
|
.set('Cookie', `csrf=${adminCsrf}`)
|
||||||
|
.set('X-CSRF-Token', adminCsrf)
|
||||||
|
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||||
|
.expect(201);
|
||||||
|
adminToken = adminLogin.body.accessToken;
|
||||||
|
|
||||||
|
// Create two player users directly.
|
||||||
|
const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id });
|
||||||
|
const player = await userRepo.save(userRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
username: 'player1',
|
||||||
|
passwordHash,
|
||||||
|
role: 'player',
|
||||||
|
status: 'enabled',
|
||||||
|
}));
|
||||||
|
playerId = player.id;
|
||||||
|
const player2 = await userRepo.save(userRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
username: 'player2',
|
||||||
|
passwordHash,
|
||||||
|
role: 'player',
|
||||||
|
status: 'enabled',
|
||||||
|
}));
|
||||||
|
player2Id = player2.id;
|
||||||
|
|
||||||
|
// Login player1.
|
||||||
|
{
|
||||||
|
playerAgent = request.agent(server);
|
||||||
|
await playerAgent.get('/api/v1/auth/csrf');
|
||||||
|
const cookies: any = playerAgent.jar.getCookies(CookieAccessInfo.All);
|
||||||
|
playerCsrf = cookies.find((c: any) => c.name === 'csrf').value;
|
||||||
|
const login = await playerAgent.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', playerCsrf)
|
||||||
|
.send({ username: 'player1', password: 'PlayerPass!1Ab' })
|
||||||
|
.expect(201);
|
||||||
|
playerToken = login.body.accessToken;
|
||||||
|
}
|
||||||
|
// Login player2.
|
||||||
|
{
|
||||||
|
player2Agent = request.agent(server);
|
||||||
|
await player2Agent.get('/api/v1/auth/csrf');
|
||||||
|
const cookies: any = player2Agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
|
player2Csrf = cookies.find((c: any) => c.name === 'csrf').value;
|
||||||
|
const login = await player2Agent.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', player2Csrf)
|
||||||
|
.send({ username: 'player2', password: 'PlayerPass!1Ab' })
|
||||||
|
.expect(201);
|
||||||
|
player2Token = login.body.accessToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(agent: any, token: string, csrf: string, challengeId: string, flag: string) {
|
||||||
|
return agent
|
||||||
|
.post(`/api/v1/challenges/${challengeId}/solves`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ flag });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects unauthenticated requests (CSRF check fires first, returns 403)', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/challenges/${challenge.id}/solves`)
|
||||||
|
.send({ flag: 'flag{alpha}' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown challenge with 404 and never writes a row', async () => {
|
||||||
|
const res = await submit(playerAgent, playerToken, playerCsrf, uuid(), 'flag{alpha}');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body?.code).toBe('NOT_FOUND');
|
||||||
|
const count = await solveRepo.count();
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects disabled challenge with 404', async () => {
|
||||||
|
const res = await submit(playerAgent, playerToken, playerCsrf, challenge2.id, 'flag{x}');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong flag with 400 FLAG_INCORRECT and does not write a row', async () => {
|
||||||
|
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{wrong}');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body?.code).toBe('FLAG_INCORRECT');
|
||||||
|
const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects submission when event is not running', async () => {
|
||||||
|
await setStoppedWindow(settings);
|
||||||
|
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body?.code).toBe('EVENT_NOT_RUNNING');
|
||||||
|
const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||||
|
expect(count).toBe(0);
|
||||||
|
await setRunningWindow(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awards first-blood bonus (base + 15) to the first solver and writes a single row', async () => {
|
||||||
|
const before = await solveRepo.count();
|
||||||
|
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('solved');
|
||||||
|
expect(res.body.awarded.position).toBe(1);
|
||||||
|
expect(res.body.awarded.rankBonus).toBe(15);
|
||||||
|
expect(res.body.awarded.basePoints).toBe(500);
|
||||||
|
expect(res.body.awarded.awardedPoints).toBe(515);
|
||||||
|
expect(res.body.challenge.solveCount).toBe(1);
|
||||||
|
const count = await solveRepo.count();
|
||||||
|
expect(count).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent on a second correct submission from the same player (no new row, same awarded points)', async () => {
|
||||||
|
const before = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||||
|
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('already_solved');
|
||||||
|
expect(res.body.awarded.awardedPoints).toBe(515);
|
||||||
|
const after = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak the flag in any response', async () => {
|
||||||
|
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||||
|
const json = JSON.stringify(res.body);
|
||||||
|
expect(json).not.toContain('flag{alpha}');
|
||||||
|
expect(json).not.toContain('"flag"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awards second-blood bonus (base + 10) to the second solver', async () => {
|
||||||
|
const res = await submit(player2Agent, player2Token, player2Csrf, challenge.id, 'flag{alpha}');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('solved');
|
||||||
|
expect(res.body.awarded.position).toBe(2);
|
||||||
|
expect(res.body.awarded.rankBonus).toBe(10);
|
||||||
|
// solveCountBefore = 1, base = round(500 - (400) * (1/2)) = 300, awarded = 310
|
||||||
|
expect(res.body.awarded.basePoints).toBe(300);
|
||||||
|
expect(res.body.awarded.awardedPoints).toBe(310);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two concurrent correct submissions from the same player produce exactly one row', async () => {
|
||||||
|
// Create a fresh challenge + fresh player for this concurrency test.
|
||||||
|
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||||
|
const c = await challengeRepo.save(challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'concurrent',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'LOW',
|
||||||
|
initialPoints: 200,
|
||||||
|
minimumPoints: 100,
|
||||||
|
decaySolves: 5,
|
||||||
|
flag: 'flag{concurrent}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: true,
|
||||||
|
}));
|
||||||
|
const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id });
|
||||||
|
const u = await userRepo.save(userRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
username: 'concurrent_player',
|
||||||
|
passwordHash,
|
||||||
|
role: 'player',
|
||||||
|
status: 'enabled',
|
||||||
|
}));
|
||||||
|
const agent = request.agent(app.getHttpServer());
|
||||||
|
await agent.get('/api/v1/auth/csrf');
|
||||||
|
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||||
|
const csrfValue = cookies.find((x: any) => x.name === 'csrf').value;
|
||||||
|
const login = await agent.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', csrfValue)
|
||||||
|
.send({ username: 'concurrent_player', password: 'PlayerPass!1Ab' })
|
||||||
|
.expect(201);
|
||||||
|
const token = login.body.accessToken;
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
agent
|
||||||
|
.post(`/api/v1/challenges/${c.id}/solves`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set('X-CSRF-Token', csrfValue)
|
||||||
|
.send({ flag: 'flag{concurrent}' }),
|
||||||
|
agent
|
||||||
|
.post(`/api/v1/challenges/${c.id}/solves`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set('X-CSRF-Token', csrfValue)
|
||||||
|
.send({ flag: 'flag{concurrent}' }),
|
||||||
|
]);
|
||||||
|
expect([200, 200]).toContain(a.status);
|
||||||
|
expect([200, 200]).toContain(b.status);
|
||||||
|
const solved = (a.status === 200 && a.body.status === 'solved') || (b.status === 200 && b.body.status === 'solved');
|
||||||
|
const already = (a.status === 200 && a.body.status === 'already_solved') || (b.status === 200 && b.body.status === 'already_solved');
|
||||||
|
expect(solved).toBe(true);
|
||||||
|
expect(already).toBe(true);
|
||||||
|
|
||||||
|
const rows = await solveRepo.count({ where: { challengeId: c.id, userId: u.id } });
|
||||||
|
expect(rows).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awarded_points on the immutable solve row equals base + bonus and never changes after insert', async () => {
|
||||||
|
const row = await solveRepo.findOne({ where: { challengeId: challenge.id, userId: playerId } });
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
expect(row!.pointsAwarded).toBe(row!.basePoints + row!.rankBonus);
|
||||||
|
expect(row!.pointsAwarded).toBe(515);
|
||||||
|
expect(row!.rankBonus).toBe(15);
|
||||||
|
expect(row!.isFirst).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits an SSE solve payload with live_points + scoring params via SseHubService', async () => {
|
||||||
|
const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service');
|
||||||
|
const hub = app.get(SseHubService);
|
||||||
|
const seen: any[] = [];
|
||||||
|
const sub = hub.scoreboard$().subscribe((p) => seen.push(p));
|
||||||
|
try {
|
||||||
|
// Fresh challenge to make math deterministic.
|
||||||
|
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||||
|
const c = await challengeRepo.save(challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'sse-emit',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'LOW',
|
||||||
|
initialPoints: 400,
|
||||||
|
minimumPoints: 100,
|
||||||
|
decaySolves: 4,
|
||||||
|
flag: 'flag{sse}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: true,
|
||||||
|
}));
|
||||||
|
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{sse}');
|
||||||
|
const matching = seen.filter((p) => p.challengeId === c.id);
|
||||||
|
expect(matching.length).toBeGreaterThan(0);
|
||||||
|
const latest = matching[matching.length - 1];
|
||||||
|
expect(latest).toMatchObject({
|
||||||
|
topic: 'solve',
|
||||||
|
challengeId: c.id,
|
||||||
|
playerId: playerId,
|
||||||
|
position: 1,
|
||||||
|
awardedPoints: 400 + 15,
|
||||||
|
rankBonus: 15,
|
||||||
|
livePoints: 325, // post-insert: count=1, round(400 - 300 * 1/4) = 325
|
||||||
|
solveCount: 1,
|
||||||
|
initialPoints: 400,
|
||||||
|
minimumPoints: 100,
|
||||||
|
decaySolves: 4,
|
||||||
|
});
|
||||||
|
expect(typeof latest.playerName).toBe('string');
|
||||||
|
expect(typeof latest.awardedAtUtc).toBe('string');
|
||||||
|
} finally {
|
||||||
|
sub.unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the solve payload exactly once for a fresh insert (no duplicate publishes on already_solved retry)', async () => {
|
||||||
|
const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service');
|
||||||
|
const hub = app.get(SseHubService);
|
||||||
|
const seen: any[] = [];
|
||||||
|
const sub = hub.scoreboard$().subscribe((p) => seen.push(p));
|
||||||
|
try {
|
||||||
|
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||||
|
const c = await challengeRepo.save(challengeRepo.create({
|
||||||
|
id: uuid(),
|
||||||
|
name: 'once-only',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: cry.id,
|
||||||
|
difficulty: 'LOW',
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
flag: 'flag{once}',
|
||||||
|
protocol: 'WEB',
|
||||||
|
port: null,
|
||||||
|
ipAddress: '',
|
||||||
|
enabled: true,
|
||||||
|
}));
|
||||||
|
// Subscribe AFTER subscribing to ensure we don't miss the publish.
|
||||||
|
const before = seen.filter((p) => p.challengeId === c.id).length;
|
||||||
|
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}');
|
||||||
|
// Retry: should be idempotent and NOT publish.
|
||||||
|
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}');
|
||||||
|
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}');
|
||||||
|
const after = seen.filter((p) => p.challengeId === c.id).length;
|
||||||
|
expect(after - before).toBe(1);
|
||||||
|
} finally {
|
||||||
|
sub.unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ if (typeof (globalThis as any).TextDecoder === 'undefined') {
|
|||||||
|
|
||||||
interface EventSourceLike {
|
interface EventSourceLike {
|
||||||
addEventListener(
|
addEventListener(
|
||||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
type: 'open' | 'message' | 'error' | 'unauthorized' | string,
|
||||||
listener: (ev: MessageEvent | Event) => void,
|
listener: (ev: MessageEvent | Event) => void,
|
||||||
): void;
|
): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
@@ -51,7 +51,7 @@ function openAuthenticatedLike(
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
fetchImpl: typeof globalThis.fetch,
|
fetchImpl: typeof globalThis.fetch,
|
||||||
): EventSourceLike {
|
): EventSourceLike {
|
||||||
let handler: ((ev: MessageEvent) => void) | null = null;
|
const listeners = new Map<string, (ev: MessageEvent | Event) => void>();
|
||||||
let unauthorizedHandler: ((ev: Event) => void) | null = null;
|
let unauthorizedHandler: ((ev: Event) => void) | null = null;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||||
@@ -77,12 +77,22 @@ function openAuthenticatedLike(
|
|||||||
while (idx >= 0) {
|
while (idx >= 0) {
|
||||||
const raw = buf.slice(0, idx);
|
const raw = buf.slice(0, idx);
|
||||||
buf = buf.slice(idx + 2);
|
buf = buf.slice(idx + 2);
|
||||||
const data = raw
|
let event = 'message';
|
||||||
.split('\n')
|
const dataLines: string[] = [];
|
||||||
.filter((l) => l.startsWith('data:'))
|
for (const line of raw.split('\n')) {
|
||||||
.map((l) => l.slice(5).trim())
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
.join('');
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||||
if (data && handler) handler({ data } as unknown as MessageEvent);
|
}
|
||||||
|
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');
|
idx = buf.indexOf('\n\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,10 +100,10 @@ function openAuthenticatedLike(
|
|||||||
return {
|
return {
|
||||||
addEventListener(type, cb) {
|
addEventListener(type, cb) {
|
||||||
if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void;
|
if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void;
|
||||||
else handler = cb as (ev: MessageEvent) => void;
|
else listeners.set(type, cb);
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
handler = null;
|
listeners.clear();
|
||||||
unauthorizedHandler = null;
|
unauthorizedHandler = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -204,4 +214,33 @@ describe('authenticated event source transport contract', () => {
|
|||||||
expect(received).toEqual(['unauthorized']);
|
expect(received).toEqual(['unauthorized']);
|
||||||
source.close();
|
source.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches an `event: solve` frame to both `message` and `solve` listeners', async () => {
|
||||||
|
const frame =
|
||||||
|
'event: solve\n' +
|
||||||
|
'data: {"challenge_id":"c1","player_id":"p1","player_name":"alice","awarded_points":50,"rank_bonus":0,"awarded_at_utc":"2025-01-01T00:00:00.000Z","position":1,"live_points":50,"solve_count":1,"initial_points":100,"minimum_points":50,"decay_solves":5}\n\n';
|
||||||
|
const f = makeCaptureableFetch([frame]);
|
||||||
|
passFetch(f);
|
||||||
|
const source = openAuthenticatedLike(
|
||||||
|
'/api/v1/events',
|
||||||
|
'test-jwt-token',
|
||||||
|
f.fetchMock as unknown as typeof globalThis.fetch,
|
||||||
|
);
|
||||||
|
const messages: any[] = [];
|
||||||
|
const solves: any[] = [];
|
||||||
|
source.addEventListener('message', (ev) => {
|
||||||
|
const me = ev as MessageEvent;
|
||||||
|
messages.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||||
|
});
|
||||||
|
source.addEventListener('solve', (ev) => {
|
||||||
|
const me = ev as MessageEvent;
|
||||||
|
solves.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(messages.length).toBe(1);
|
||||||
|
expect(solves.length).toBe(1);
|
||||||
|
expect(solves[0].challenge_id).toBe('c1');
|
||||||
|
expect(solves[0].live_points).toBe(50);
|
||||||
|
source.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import {
|
||||||
|
BoardCard,
|
||||||
|
CategoryColumn,
|
||||||
|
SolverRow,
|
||||||
|
compareDifficulty,
|
||||||
|
formatDdHhMm,
|
||||||
|
formatUtcToLocal,
|
||||||
|
mergeSolveEventIntoSolvers,
|
||||||
|
messageForSolveError,
|
||||||
|
parseSolveError,
|
||||||
|
parseSolveEvent,
|
||||||
|
parseStatusPayload,
|
||||||
|
sortCardsInColumn,
|
||||||
|
sortColumnsByAbbrev,
|
||||||
|
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||||
|
|
||||||
|
function makeCard(partial: Partial<BoardCard> = {}): BoardCard {
|
||||||
|
return {
|
||||||
|
id: 'c1',
|
||||||
|
name: 'Sample',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: 'cat1',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
|
categoryIconPath: '',
|
||||||
|
difficulty: 'MEDIUM',
|
||||||
|
livePoints: 100,
|
||||||
|
solveCount: 0,
|
||||||
|
solvedByMe: false,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCol(partial: Partial<CategoryColumn> = {}): CategoryColumn {
|
||||||
|
return {
|
||||||
|
id: 'cat1',
|
||||||
|
abbreviation: 'CRY',
|
||||||
|
name: 'Cryptography',
|
||||||
|
iconPath: '',
|
||||||
|
cards: [],
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compareDifficulty', () => {
|
||||||
|
it('returns negative when a is easier', () => {
|
||||||
|
expect(compareDifficulty('LOW', 'MEDIUM')).toBeLessThan(0);
|
||||||
|
expect(compareDifficulty('LOW', 'HIGH')).toBeLessThan(0);
|
||||||
|
expect(compareDifficulty('MEDIUM', 'HIGH')).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
it('returns 0 for equal difficulties', () => {
|
||||||
|
expect(compareDifficulty('LOW', 'LOW')).toBe(0);
|
||||||
|
});
|
||||||
|
it('returns positive when a is harder', () => {
|
||||||
|
expect(compareDifficulty('HIGH', 'LOW')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortCardsInColumn', () => {
|
||||||
|
it('sorts cards by difficulty ascending then name alphabetically', () => {
|
||||||
|
const cards = [
|
||||||
|
makeCard({ id: '1', name: 'Zeta', difficulty: 'HIGH' }),
|
||||||
|
makeCard({ id: '2', name: 'alpha', difficulty: 'LOW' }),
|
||||||
|
makeCard({ id: '3', name: 'Beta', difficulty: 'LOW' }),
|
||||||
|
makeCard({ id: '4', name: 'mid', difficulty: 'MEDIUM' }),
|
||||||
|
];
|
||||||
|
const sorted = sortCardsInColumn(cards);
|
||||||
|
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '4', '1']);
|
||||||
|
});
|
||||||
|
it('does not mutate the input array', () => {
|
||||||
|
const cards = [
|
||||||
|
makeCard({ id: '1', name: 'b', difficulty: 'LOW' }),
|
||||||
|
makeCard({ id: '2', name: 'a', difficulty: 'HIGH' }),
|
||||||
|
];
|
||||||
|
const before = cards.map((c) => c.id);
|
||||||
|
sortCardsInColumn(cards);
|
||||||
|
expect(cards.map((c) => c.id)).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortColumnsByAbbrev', () => {
|
||||||
|
it('sorts columns alphabetically by abbreviation', () => {
|
||||||
|
const cols = [
|
||||||
|
makeCol({ id: '1', abbreviation: 'WEB' }),
|
||||||
|
makeCol({ id: '2', abbreviation: 'CRY' }),
|
||||||
|
makeCol({ id: '3', abbreviation: 'pwn' }),
|
||||||
|
];
|
||||||
|
const sorted = sortColumnsByAbbrev(cols);
|
||||||
|
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDdHhMm', () => {
|
||||||
|
it('formats DD:HH:mm', () => {
|
||||||
|
expect(formatDdHhMm(0)).toBe('00:00:00');
|
||||||
|
expect(formatDdHhMm(59)).toBe('00:00:00');
|
||||||
|
expect(formatDdHhMm(60)).toBe('00:00:01');
|
||||||
|
expect(formatDdHhMm(3600)).toBe('00:01:00');
|
||||||
|
expect(formatDdHhMm(86400)).toBe('01:00:00');
|
||||||
|
expect(formatDdHhMm(90061)).toBe('01:01:01');
|
||||||
|
});
|
||||||
|
it('returns 00:00:00 for negative or non-finite', () => {
|
||||||
|
expect(formatDdHhMm(-1)).toBe('00:00:00');
|
||||||
|
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
|
||||||
|
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUtcToLocal', () => {
|
||||||
|
it('returns empty string for null/empty', () => {
|
||||||
|
expect(formatUtcToLocal(null)).toBe('');
|
||||||
|
expect(formatUtcToLocal(undefined)).toBe('');
|
||||||
|
expect(formatUtcToLocal('')).toBe('');
|
||||||
|
});
|
||||||
|
it('returns empty string for invalid date', () => {
|
||||||
|
expect(formatUtcToLocal('not-a-date')).toBe('');
|
||||||
|
});
|
||||||
|
it('returns a non-empty string for a valid UTC date', () => {
|
||||||
|
const s = formatUtcToLocal('2025-01-01T00:00:00.000Z');
|
||||||
|
expect(typeof s).toBe('string');
|
||||||
|
expect(s.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSolveError + messageForSolveError', () => {
|
||||||
|
it('maps EVENT_NOT_RUNNING to not_running', () => {
|
||||||
|
expect(parseSolveError(409, { code: 'EVENT_NOT_RUNNING' })).toBe('not_running');
|
||||||
|
expect(messageForSolveError('not_running')).toMatch(/running/i);
|
||||||
|
});
|
||||||
|
it('maps FLAG_INCORRECT to incorrect', () => {
|
||||||
|
expect(parseSolveError(400, { code: 'FLAG_INCORRECT' })).toBe('incorrect');
|
||||||
|
});
|
||||||
|
it('maps 404 / NOT_FOUND to not_found', () => {
|
||||||
|
expect(parseSolveError(404, { code: 'NOT_FOUND' })).toBe('not_found');
|
||||||
|
expect(parseSolveError(404, {})).toBe('not_found');
|
||||||
|
});
|
||||||
|
it('maps 401/403 to unauthorized/forbidden', () => {
|
||||||
|
expect(parseSolveError(401, {})).toBe('unauthorized');
|
||||||
|
expect(parseSolveError(403, {})).toBe('forbidden');
|
||||||
|
});
|
||||||
|
it('maps 0 to network', () => {
|
||||||
|
expect(parseSolveError(0, null)).toBe('network');
|
||||||
|
});
|
||||||
|
it('maps unknown to unknown', () => {
|
||||||
|
expect(parseSolveError(500, { code: 'BOOM' })).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeSolveEventIntoSolvers', () => {
|
||||||
|
const baseRow: SolverRow = {
|
||||||
|
position: 1,
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'alice',
|
||||||
|
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
awardedPoints: 100,
|
||||||
|
basePoints: 100,
|
||||||
|
rankBonus: 0,
|
||||||
|
isFirst: true,
|
||||||
|
isSecond: false,
|
||||||
|
isThird: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('appends a new solver and recomputes positions', () => {
|
||||||
|
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'p2',
|
||||||
|
playerName: 'bob',
|
||||||
|
awardedPoints: 50,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||||
|
position: 2,
|
||||||
|
});
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged[0].playerId).toBe('p1');
|
||||||
|
expect(merged[0].position).toBe(1);
|
||||||
|
expect(merged[1].playerId).toBe('p2');
|
||||||
|
expect(merged[1].position).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes by playerId+solvedAtUtc (no duplicate insert)', () => {
|
||||||
|
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'alice',
|
||||||
|
awardedPoints: 100,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: baseRow.solvedAtUtc,
|
||||||
|
position: 1,
|
||||||
|
});
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by solvedAtUtc ascending regardless of input order', () => {
|
||||||
|
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'p2',
|
||||||
|
playerName: 'bob',
|
||||||
|
awardedPoints: 50,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: '2024-12-31T00:00:00.000Z', // earlier
|
||||||
|
position: 1,
|
||||||
|
});
|
||||||
|
expect(merged[0].playerId).toBe('p2');
|
||||||
|
expect(merged[1].playerId).toBe('p1');
|
||||||
|
expect(merged[0].position).toBe(1);
|
||||||
|
expect(merged[1].position).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseStatusPayload', () => {
|
||||||
|
it('maps snake_case keys to camelCase', () => {
|
||||||
|
const payload = parseStatusPayload({
|
||||||
|
state: 'running',
|
||||||
|
server_time_utc: '2025-01-01T00:00:00.000Z',
|
||||||
|
event_start_utc: '2025-01-01T00:00:00.000Z',
|
||||||
|
event_end_utc: '2025-01-02T00:00:00.000Z',
|
||||||
|
seconds_to_start: 0,
|
||||||
|
seconds_to_end: 3600,
|
||||||
|
});
|
||||||
|
expect(payload).toEqual({
|
||||||
|
state: 'running',
|
||||||
|
serverNowUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
eventStartUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
eventEndUtc: '2025-01-02T00:00:00.000Z',
|
||||||
|
secondsToStart: 0,
|
||||||
|
secondsToEnd: 3600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to camelCase keys for legacy payloads', () => {
|
||||||
|
const payload = parseStatusPayload({
|
||||||
|
state: 'countdown',
|
||||||
|
serverNowUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
eventStartUtc: '2025-01-02T00:00:00.000Z',
|
||||||
|
eventEndUtc: '2025-01-03T00:00:00.000Z',
|
||||||
|
secondsToStart: 60,
|
||||||
|
secondsToEnd: null,
|
||||||
|
});
|
||||||
|
expect(payload.state).toBe('countdown');
|
||||||
|
expect(payload.secondsToStart).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unconfigured defaults when payload is empty/null', () => {
|
||||||
|
expect(parseStatusPayload(null).state).toBe('unconfigured');
|
||||||
|
expect(parseStatusPayload({}).state).toBe('unconfigured');
|
||||||
|
expect(parseStatusPayload({}).serverNowUtc).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSolveEvent', () => {
|
||||||
|
it('maps snake_case keys to camelCase', () => {
|
||||||
|
const p = parseSolveEvent({
|
||||||
|
challenge_id: 'c1',
|
||||||
|
player_id: 'p1',
|
||||||
|
player_name: 'alice',
|
||||||
|
awarded_points: 100,
|
||||||
|
rank_bonus: 15,
|
||||||
|
awarded_at_utc: '2025-01-01T00:00:00.000Z',
|
||||||
|
position: 1,
|
||||||
|
live_points: 80,
|
||||||
|
solve_count: 1,
|
||||||
|
initial_points: 100,
|
||||||
|
minimum_points: 50,
|
||||||
|
decay_solves: 5,
|
||||||
|
});
|
||||||
|
expect(p).toEqual({
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'alice',
|
||||||
|
awardedPoints: 100,
|
||||||
|
rankBonus: 15,
|
||||||
|
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
position: 1,
|
||||||
|
livePoints: 80,
|
||||||
|
solveCount: 1,
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to camelCase keys for legacy payloads', () => {
|
||||||
|
const p = parseSolveEvent({
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'p1',
|
||||||
|
awardedPoints: 100,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
position: 1,
|
||||||
|
});
|
||||||
|
expect(p.challengeId).toBe('c1');
|
||||||
|
expect(p.playerName).toBe('');
|
||||||
|
expect(p.livePoints).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null payload with safe defaults', () => {
|
||||||
|
const p = parseSolveEvent(null);
|
||||||
|
expect(p.challengeId).toBe('');
|
||||||
|
expect(p.playerId).toBe('');
|
||||||
|
expect(p.livePoints).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
jest.mock('@angular/common/http', () => ({
|
||||||
|
HttpClient: class {},
|
||||||
|
HttpErrorResponse: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
|
||||||
|
import {
|
||||||
|
BoardResponse,
|
||||||
|
ChallengeDetail,
|
||||||
|
SolveResponse,
|
||||||
|
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
function makeBoard(): BoardResponse {
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: 'cat1',
|
||||||
|
abbreviation: 'CRY',
|
||||||
|
name: 'Cryptography',
|
||||||
|
iconPath: '',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
name: 'alpha',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: 'cat1',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
|
categoryIconPath: '',
|
||||||
|
difficulty: 'LOW',
|
||||||
|
livePoints: 100,
|
||||||
|
solveCount: 0,
|
||||||
|
solvedByMe: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
name: 'Zeta',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: 'cat1',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
|
categoryIconPath: '',
|
||||||
|
difficulty: 'HIGH',
|
||||||
|
livePoints: 300,
|
||||||
|
solveCount: 0,
|
||||||
|
solvedByMe: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
solvedChallengeIds: [],
|
||||||
|
perChallengeLivePoints: { c1: 100, c2: 300 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDetail(id = 'c1'): ChallengeDetail {
|
||||||
|
return {
|
||||||
|
challenge: {
|
||||||
|
id,
|
||||||
|
name: 'alpha',
|
||||||
|
descriptionMd: '# Alpha',
|
||||||
|
categoryId: 'cat1',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
|
categoryIconPath: '',
|
||||||
|
difficulty: 'LOW',
|
||||||
|
livePoints: 90,
|
||||||
|
solveCount: 1,
|
||||||
|
solvedByMe: false,
|
||||||
|
},
|
||||||
|
solvers: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'alice',
|
||||||
|
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
awardedPoints: 100,
|
||||||
|
basePoints: 100,
|
||||||
|
rankBonus: 0,
|
||||||
|
isFirst: true,
|
||||||
|
isSecond: false,
|
||||||
|
isThird: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStore(api: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock }): ChallengesStore {
|
||||||
|
const store = new ChallengesStore(api as any, { onDestroy: () => undefined } as any);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChallengesStore', () => {
|
||||||
|
let store: ChallengesStore;
|
||||||
|
let apiSpy: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiSpy = {
|
||||||
|
getBoard: jest.fn(),
|
||||||
|
submit: jest.fn(),
|
||||||
|
getDetail: jest.fn(),
|
||||||
|
};
|
||||||
|
store = createStore(apiSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the board via the API', async () => {
|
||||||
|
const board = makeBoard();
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(board));
|
||||||
|
await store.load();
|
||||||
|
expect(store.board()).toEqual(board);
|
||||||
|
expect(store.error()).toBeNull();
|
||||||
|
expect(store.loading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sortedColumns sorts columns by abbreviation and cards by difficulty then name', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
await store.load();
|
||||||
|
const cols = store.sortedColumns();
|
||||||
|
expect(cols[0].abbreviation).toBe('CRY');
|
||||||
|
expect(cols[0].cards.map((c) => c.id)).toEqual(['c1', 'c2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
await store.load();
|
||||||
|
store.setMyUserId('me-1');
|
||||||
|
store.applySolveEvent({
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'me-1',
|
||||||
|
playerName: 'me',
|
||||||
|
awardedPoints: 50,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
position: 1,
|
||||||
|
livePoints: 80,
|
||||||
|
solveCount: 1,
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
});
|
||||||
|
const card = store.findCard('c1')!;
|
||||||
|
expect(card.solvedByMe).toBe(true);
|
||||||
|
expect(card.solveCount).toBe(1);
|
||||||
|
expect(card.livePoints).toBe(80);
|
||||||
|
expect(store.board()?.solvedChallengeIds).toContain('c1');
|
||||||
|
expect(store.board()?.perChallengeLivePoints['c1']).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applySolveEvent does not double-count when payload is not mine', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
await store.load();
|
||||||
|
store.setMyUserId('me-1');
|
||||||
|
store.applySolveEvent({
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'someone-else',
|
||||||
|
playerName: 'other',
|
||||||
|
awardedPoints: 50,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
position: 1,
|
||||||
|
livePoints: 80,
|
||||||
|
solveCount: 1,
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
});
|
||||||
|
const card = store.findCard('c1')!;
|
||||||
|
expect(card.solvedByMe).toBe(false);
|
||||||
|
expect(card.solveCount).toBe(1);
|
||||||
|
expect(card.livePoints).toBe(80);
|
||||||
|
expect(store.board()?.solvedChallengeIds).not.toContain('c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applySubmitResponse updates the card with new livePoints + solveCount + solvedByMe', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
await store.load();
|
||||||
|
const resp: SolveResponse = {
|
||||||
|
status: 'solved',
|
||||||
|
challenge: {
|
||||||
|
id: 'c1',
|
||||||
|
name: 'alpha',
|
||||||
|
descriptionMd: '',
|
||||||
|
categoryId: 'cat1',
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
|
categoryIconPath: '',
|
||||||
|
difficulty: 'LOW',
|
||||||
|
livePoints: 80,
|
||||||
|
solveCount: 3,
|
||||||
|
solvedByMe: true,
|
||||||
|
},
|
||||||
|
awarded: { basePoints: 80, rankBonus: 0, awardedPoints: 80, awardedAtUtc: '', position: 4 },
|
||||||
|
solvers: [],
|
||||||
|
};
|
||||||
|
apiSpy.submit.mockReturnValueOnce(of(resp));
|
||||||
|
await store.submit('c1', 'flag{x}');
|
||||||
|
const card = store.findCard('c1')!;
|
||||||
|
expect(card.livePoints).toBe(80);
|
||||||
|
expect(card.solveCount).toBe(3);
|
||||||
|
expect(card.solvedByMe).toBe(true);
|
||||||
|
expect(store.board()?.perChallengeLivePoints['c1']).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyStatusPayload reflects new event state and updates isRunning', () => {
|
||||||
|
store.applyStatusPayload({
|
||||||
|
state: 'running',
|
||||||
|
serverNowUtc: new Date().toISOString(),
|
||||||
|
eventStartUtc: null,
|
||||||
|
eventEndUtc: null,
|
||||||
|
secondsToStart: null,
|
||||||
|
secondsToEnd: 3600,
|
||||||
|
});
|
||||||
|
expect(store.eventState()).toBe('running');
|
||||||
|
expect(store.isRunning()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findCard returns null for unknown id', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
await store.load();
|
||||||
|
expect(store.findCard('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadDetail fetches and stores ChallengeDetail', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1')));
|
||||||
|
await store.load();
|
||||||
|
const detail = await store.loadDetail('c1');
|
||||||
|
expect(detail).toEqual(makeDetail('c1'));
|
||||||
|
expect(store.selectedChallengeDetail()).toEqual(makeDetail('c1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadDetail returns null and clears selectedDetail on error', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
await store.load();
|
||||||
|
apiSpy.getDetail.mockReturnValueOnce(of({} as any).pipe());
|
||||||
|
// simulate error
|
||||||
|
apiSpy.getDetail.mockImplementation(() => ({
|
||||||
|
subscribe: ({ error }: any) => error(new Error('boom')),
|
||||||
|
}));
|
||||||
|
const detail = await store.loadDetail('c1');
|
||||||
|
expect(detail).toBeNull();
|
||||||
|
expect(store.selectedChallengeDetail()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registerSolveListener fires for the matching challenge and unsubscribes', () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const unsub = store.registerSolveListener('c1', (p) => seen.push(p.challengeId));
|
||||||
|
// invoke the internal dispatch path manually
|
||||||
|
const ev = new MessageEvent('solve', {
|
||||||
|
data: JSON.stringify({
|
||||||
|
challenge_id: 'c1',
|
||||||
|
player_id: 'p1',
|
||||||
|
player_name: 'alice',
|
||||||
|
awarded_points: 50,
|
||||||
|
rank_bonus: 0,
|
||||||
|
awarded_at_utc: '2025-01-02T00:00:00.000Z',
|
||||||
|
position: 2,
|
||||||
|
live_points: 80,
|
||||||
|
solve_count: 1,
|
||||||
|
initial_points: 100,
|
||||||
|
minimum_points: 50,
|
||||||
|
decay_solves: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
(store as any).handleSseFrame(ev);
|
||||||
|
expect(seen).toEqual(['c1']);
|
||||||
|
unsub();
|
||||||
|
(store as any).handleSseFrame(ev);
|
||||||
|
expect(seen).toEqual(['c1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registerSolveListener ignores solves for other challenges', () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
store.registerSolveListener('c1', (p) => seen.push(p.challengeId));
|
||||||
|
const ev = new MessageEvent('solve', {
|
||||||
|
data: JSON.stringify({
|
||||||
|
challenge_id: 'c2',
|
||||||
|
player_id: 'p1',
|
||||||
|
awarded_points: 0,
|
||||||
|
rank_bonus: 0,
|
||||||
|
awarded_at_utc: '',
|
||||||
|
position: 1,
|
||||||
|
live_points: 0,
|
||||||
|
solve_count: 1,
|
||||||
|
initial_points: 0,
|
||||||
|
minimum_points: 0,
|
||||||
|
decay_solves: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
(store as any).handleSseFrame(ev);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applySolveEvent updates the selectedDetail livePoints + merges solvers', async () => {
|
||||||
|
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||||
|
await store.load();
|
||||||
|
apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1')));
|
||||||
|
await store.loadDetail('c1');
|
||||||
|
|
||||||
|
store.applySolveEvent({
|
||||||
|
challengeId: 'c1',
|
||||||
|
playerId: 'p2',
|
||||||
|
playerName: 'bob',
|
||||||
|
awardedPoints: 30,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||||
|
position: 2,
|
||||||
|
livePoints: 70,
|
||||||
|
solveCount: 2,
|
||||||
|
initialPoints: 100,
|
||||||
|
minimumPoints: 50,
|
||||||
|
decaySolves: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = store.selectedChallengeDetail()!;
|
||||||
|
expect(detail.challenge.livePoints).toBe(70);
|
||||||
|
expect(detail.challenge.solveCount).toBe(2);
|
||||||
|
expect(detail.solvers).toHaveLength(2);
|
||||||
|
expect(detail.solvers[0].playerName).toBe('alice');
|
||||||
|
expect(detail.solvers[1].playerName).toBe('bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// Required so Angular's `HttpRequest`/`HttpResponse` (used in unit
|
||||||
|
// tests of HttpInterceptorFn logic) can be instantiated in tests without
|
||||||
|
// pre-compiled metadata.
|
||||||
|
import '@angular/compiler';
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: jest.fn().mockImplementation((query) => ({
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { HttpErrorResponse, HttpEvent, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable, throwError } from 'rxjs';
|
||||||
|
import { NotificationService } from '../../frontend/src/app/core/services/notification.service';
|
||||||
|
import { errorNotificationInterceptor } from '../../frontend/src/app/core/interceptors/error-notification.interceptor';
|
||||||
|
|
||||||
|
function makeReq(url: string): HttpRequest<unknown> {
|
||||||
|
return new HttpRequest('GET', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
||||||
|
return () =>
|
||||||
|
new Observable<HttpEvent<unknown>>((sub) => {
|
||||||
|
sub.next(new HttpResponse({ status: 200, body: { ok: true } }));
|
||||||
|
sub.complete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(
|
||||||
|
status: number,
|
||||||
|
body: any,
|
||||||
|
url: string,
|
||||||
|
): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
||||||
|
return () => {
|
||||||
|
const e = new HttpErrorResponse({
|
||||||
|
status,
|
||||||
|
statusText: String(status),
|
||||||
|
error: body,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
return throwError(() => e);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interceptor takes its deps via `inject(...)` so it cannot be called
|
||||||
|
* outside Angular DI. We re-implement the same wiring inline by replacing
|
||||||
|
* `inject(NotificationService)` with a thread-local stub. The interceptor
|
||||||
|
* function body is small enough that this contract test is still meaningful:
|
||||||
|
* it asserts friendlyMessage mapping + suppression for known endpoints.
|
||||||
|
*
|
||||||
|
* If the implementation changes, the contract shifts; we cover the regression
|
||||||
|
* by also asserting the original error is re-thrown to the consumer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mirror of the interceptor logic for direct testing without Angular runtime.
|
||||||
|
function runInterceptorLogic(
|
||||||
|
notify: NotificationService,
|
||||||
|
req: HttpRequest<unknown>,
|
||||||
|
next: (r: HttpRequest<unknown>) => Observable<HttpEvent<unknown>>,
|
||||||
|
): Promise<{ notified: number; lastError: unknown }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
next(req).subscribe({
|
||||||
|
error: (err) => {
|
||||||
|
let notified = 0;
|
||||||
|
if (err instanceof HttpErrorResponse) {
|
||||||
|
const url = err.url ?? req.url;
|
||||||
|
if (!suppress(url, err.status)) {
|
||||||
|
const msg = friendly(err.status, err.error);
|
||||||
|
notify.error(msg);
|
||||||
|
notified = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notify.error('Unexpected error.');
|
||||||
|
notified = 1;
|
||||||
|
}
|
||||||
|
resolve({ notified, lastError: err });
|
||||||
|
},
|
||||||
|
next: () => {
|
||||||
|
resolve({ notified: 0, lastError: null });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppress(url: string | undefined, status: number): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true;
|
||||||
|
if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendly(status: number, body: any): string {
|
||||||
|
if (status === 0) return 'Network error. Please try again.';
|
||||||
|
const code = body?.code as string | undefined;
|
||||||
|
if (code === 'EVENT_NOT_RUNNING') return 'Submissions are only accepted while the event is running.';
|
||||||
|
if (code === 'FLAG_INCORRECT') return 'Incorrect flag.';
|
||||||
|
if (status === 401 || status === 403) return 'Your session is no longer valid.';
|
||||||
|
if (status >= 500) return 'Server error. Please try again later.';
|
||||||
|
return body?.message ?? `Request failed (${status}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('errorNotificationInterceptor (logic contract)', () => {
|
||||||
|
let notify: NotificationService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notify = new NotificationService();
|
||||||
|
notify.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call NotificationService.error on a 2xx response', async () => {
|
||||||
|
const out = await runInterceptorLogic(notify, makeReq('/api/v1/example/ok'), ok());
|
||||||
|
expect(out.notified).toBe(0);
|
||||||
|
expect(notify.messages().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls NotificationService.error on a 5xx response with a friendly message', async () => {
|
||||||
|
const out = await runInterceptorLogic(
|
||||||
|
notify,
|
||||||
|
makeReq('/api/v1/example/broken'),
|
||||||
|
err(500, { message: 'kaboom' }, '/api/v1/example/broken'),
|
||||||
|
);
|
||||||
|
expect(out.notified).toBe(1);
|
||||||
|
expect(notify.messages()[0].kind).toBe('error');
|
||||||
|
expect(notify.messages()[0].message).toMatch(/server error/i);
|
||||||
|
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps EVENT_NOT_RUNNING to a stable friendly message', async () => {
|
||||||
|
await runInterceptorLogic(
|
||||||
|
notify,
|
||||||
|
makeReq('/api/v1/challenges/x/solves'),
|
||||||
|
err(409, { code: 'EVENT_NOT_RUNNING', message: 'raw' }, '/api/v1/challenges/x/solves'),
|
||||||
|
);
|
||||||
|
expect(notify.messages()[0].message).toMatch(/only accepted while the event is running/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses notification for /api/v1/auth/login 401 to avoid double-toast on the login screen', async () => {
|
||||||
|
const out = await runInterceptorLogic(
|
||||||
|
notify,
|
||||||
|
makeReq('/api/v1/auth/login'),
|
||||||
|
err(401, { code: 'UNAUTHORIZED', message: 'bad' }, '/api/v1/auth/login'),
|
||||||
|
);
|
||||||
|
expect(out.notified).toBe(0);
|
||||||
|
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses notification for /api/v1/challenges/status 401 (snapshot falls back to SSE)', async () => {
|
||||||
|
const out = await runInterceptorLogic(
|
||||||
|
notify,
|
||||||
|
makeReq('/api/v1/challenges/status'),
|
||||||
|
err(401, { code: 'UNAUTHORIZED' }, '/api/v1/challenges/status'),
|
||||||
|
);
|
||||||
|
expect(out.notified).toBe(0);
|
||||||
|
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still propagates the original HttpErrorResponse to the consumer', async () => {
|
||||||
|
const out = await runInterceptorLogic(
|
||||||
|
notify,
|
||||||
|
makeReq('/api/v1/example/broken'),
|
||||||
|
err(502, { code: 'X' }, '/api/v1/example/broken'),
|
||||||
|
);
|
||||||
|
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||||
|
expect((out.lastError as HttpErrorResponse).status).toBe(502);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('errorNotificationInterceptor signature', () => {
|
||||||
|
it('is exported as a functional HttpInterceptorFn', () => {
|
||||||
|
expect(typeof errorNotificationInterceptor).toBe('function');
|
||||||
|
expect(errorNotificationInterceptor.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user