feat: Challenges Page and Challenge Solve Modal
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 { UploadsModule } from './modules/uploads/uploads.module';
|
||||
import { BlogModule } from './modules/blog/blog.module';
|
||||
import { ChallengesModule } from './modules/challenges/challenges.module';
|
||||
import { FrontendModule } from './frontend/frontend.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
@@ -33,6 +34,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
AdminModule,
|
||||
UploadsModule,
|
||||
BlogModule,
|
||||
ChallengesModule,
|
||||
FrontendModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -27,6 +27,9 @@ export const ERROR_CODES = {
|
||||
CHALLENGE_FILE_LIMIT: 'CHALLENGE_FILE_LIMIT',
|
||||
IMPORT_VALIDATION_FAILED: 'IMPORT_VALIDATION_FAILED',
|
||||
IMPORT_PARTIAL_FAILURE: 'IMPORT_PARTIAL_FAILURE',
|
||||
EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING',
|
||||
FLAG_INCORRECT: 'FLAG_INCORRECT',
|
||||
CHALLENGE_DISABLED: 'CHALLENGE_DISABLED',
|
||||
INTERNAL: 'INTERNAL',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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,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 = [];
|
||||
for (const m of items) {
|
||||
const me = new MessageEvent(m.event, { data: m.data });
|
||||
// Dispatch to 'message' listeners first so existing consumers
|
||||
// that only listen for 'message' still receive every frame.
|
||||
dispatch('message', me);
|
||||
if (m.event && m.event !== 'message') {
|
||||
dispatch(m.event, me);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,8 +104,12 @@ export class AuthenticatedEventSourceService {
|
||||
}
|
||||
if (dataLines.length === 0) return;
|
||||
const payload = dataLines.join('\n');
|
||||
if (listeners.has('message')) {
|
||||
dispatch('message', new MessageEvent(event, { data: payload }));
|
||||
if (listeners.size > 0) {
|
||||
const me = new MessageEvent(event, { data: payload });
|
||||
dispatch('message', me);
|
||||
if (event && event !== 'message' && listeners.has(event)) {
|
||||
dispatch(event, me);
|
||||
}
|
||||
} else {
|
||||
buffered.push({ event, data: payload });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface EventStatePayload {
|
||||
|
||||
export interface EventSourceLike {
|
||||
addEventListener(
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized' | string,
|
||||
listener: (ev: MessageEvent | Event) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
|
||||
@@ -26,14 +26,28 @@ export class EventStatusStore {
|
||||
readonly serverNowUtc = signal<string | null>(null);
|
||||
readonly eventStartUtc = 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);
|
||||
|
||||
/**
|
||||
* 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>(() => {
|
||||
void this.tick();
|
||||
const s = this.eventStartUtc();
|
||||
if (!s) return null;
|
||||
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
|
||||
const anchor = new Date(this.currentServerTimeUtc()).getTime();
|
||||
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||
return diff > 0 ? diff : 0;
|
||||
});
|
||||
@@ -42,7 +56,7 @@ export class EventStatusStore {
|
||||
void this.tick();
|
||||
const s = this.eventEndUtc();
|
||||
if (!s) return null;
|
||||
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
|
||||
const anchor = new Date(this.currentServerTimeUtc()).getTime();
|
||||
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||
return diff > 0 ? diff : 0;
|
||||
});
|
||||
@@ -51,8 +65,14 @@ export class EventStatusStore {
|
||||
return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd());
|
||||
});
|
||||
|
||||
readonly secondsToStartPublic = computed<number | null>(() => this.secondsToStart());
|
||||
readonly secondsToEndPublic = computed<number | null>(() => this.secondsToEnd());
|
||||
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private source: EventSourceLike | null = null;
|
||||
private zeroWatcher: ReturnType<typeof setInterval> | null = null;
|
||||
private reloadOnZero: (() => void) | null = null;
|
||||
private reloadOnZeroFired = false;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.stop());
|
||||
@@ -63,14 +83,18 @@ export class EventStatusStore {
|
||||
this.serverNowUtc.set(payload.serverNowUtc);
|
||||
this.eventStartUtc.set(payload.eventStartUtc);
|
||||
this.eventEndUtc.set(payload.eventEndUtc);
|
||||
this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime());
|
||||
this.clientAnchorMs.set(Date.now());
|
||||
// Reset the one-shot so a subsequent transition (e.g. countdown→running) can fire again.
|
||||
if (this.reloadOnZeroFired) {
|
||||
this.reloadOnZeroFired = false;
|
||||
}
|
||||
}
|
||||
|
||||
start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
|
||||
this.stop();
|
||||
const src = createSource();
|
||||
this.source = src;
|
||||
src.addEventListener('message', (ev) => {
|
||||
const handler = (ev: MessageEvent | Event) => {
|
||||
const me = ev as MessageEvent;
|
||||
try {
|
||||
const data = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
|
||||
@@ -80,7 +104,9 @@ export class EventStatusStore {
|
||||
} catch {
|
||||
// ignore malformed frame
|
||||
}
|
||||
});
|
||||
};
|
||||
src.addEventListener('message', handler);
|
||||
src.addEventListener('status', handler);
|
||||
if (onUnauthorized) {
|
||||
src.addEventListener('unauthorized', () => {
|
||||
try {
|
||||
@@ -93,6 +119,32 @@ export class EventStatusStore {
|
||||
if (!this.intervalId) {
|
||||
this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000);
|
||||
}
|
||||
if (!this.zeroWatcher) {
|
||||
this.zeroWatcher = setInterval(() => this.checkZero(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
reloadAtCountdownZero(handler: () => void): void {
|
||||
this.reloadOnZero = handler;
|
||||
this.reloadOnZeroFired = false;
|
||||
}
|
||||
|
||||
private checkZero(): void {
|
||||
if (!this.reloadOnZero || this.reloadOnZeroFired) return;
|
||||
const state = this.state();
|
||||
if (state === 'countdown') {
|
||||
const s = this.secondsToStart();
|
||||
if (s !== null && s <= 0) {
|
||||
this.reloadOnZeroFired = true;
|
||||
try { this.reloadOnZero(); } catch { /* ignore */ }
|
||||
}
|
||||
} else if (state === 'running') {
|
||||
const s = this.secondsToEnd();
|
||||
if (s !== null && s <= 0) {
|
||||
this.reloadOnZeroFired = true;
|
||||
try { this.reloadOnZero(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -108,5 +160,9 @@ export class EventStatusStore {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
if (this.zeroWatcher) {
|
||||
clearInterval(this.zeroWatcher);
|
||||
this.zeroWatcher = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
selector: 'app-challenges-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, CategoryColumnComponent, ChallengeModalComponent],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.wrap { padding: 12px 0; }
|
||||
.grid {
|
||||
display: flex; gap: 12px;
|
||||
overflow-x: auto; overflow-y: visible;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.gate {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 240px; flex-direction: column; gap: 8px;
|
||||
padding: 32px;
|
||||
}
|
||||
.gate h2 { margin: 0; }
|
||||
.countdown {
|
||||
font-variant-numeric: tabular-nums; font-size: 28px; font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.label { opacity: 0.7; }
|
||||
.error { color: var(--color-danger, #ef4444); }
|
||||
`],
|
||||
template: `
|
||||
<section class="page-challenges">
|
||||
<h2>Challenges</h2>
|
||||
<p>Challenge list will appear here.</p>
|
||||
<section class="wrap" data-testid="challenges-page">
|
||||
<ng-container *ngIf="isRunning(); else gate">
|
||||
<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>
|
||||
`,
|
||||
})
|
||||
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 { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
||||
import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor';
|
||||
import { errorNotificationInterceptor } from './app/core/interceptors/error-notification.interceptor';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])),
|
||||
provideHttpClient(
|
||||
withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor]),
|
||||
),
|
||||
provideRouter(APP_ROUTES, withComponentInputBinding()),
|
||||
],
|
||||
}).catch((err) => console.error(err));
|
||||
@@ -0,0 +1,242 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-board-test';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
||||
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||
|
||||
async function setRunningWindow(svc: SettingsService): Promise<void> {
|
||||
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString());
|
||||
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
|
||||
}
|
||||
|
||||
describe('GET /api/v1/challenges/board (authenticated)', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let adminCsrf: string;
|
||||
let settings: SettingsService;
|
||||
let challengeRepo: Repository<ChallengeEntity>;
|
||||
let categoryRepo: Repository<CategoryEntity>;
|
||||
let cry: CategoryEntity;
|
||||
let web: CategoryEntity;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
|
||||
settings = app.get(SettingsService);
|
||||
challengeRepo = app.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||
categoryRepo = app.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||
|
||||
cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||
web = (await categoryRepo.findOne({ where: { systemKey: 'WEB' } }))!;
|
||||
if (!cry || !web) throw new Error('expected CRY and WEB seeded categories');
|
||||
|
||||
// Seed: 2 challenges under CRY (LOW, HIGH) and 1 under WEB (MEDIUM); one disabled.
|
||||
await challengeRepo.save([
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'alphacipher',
|
||||
descriptionMd: '# Alpha\n',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 200,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{alpha}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}),
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'Zeta-key',
|
||||
descriptionMd: '# Zeta\n',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'HIGH',
|
||||
initialPoints: 500,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 10,
|
||||
flag: 'flag{zeta}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}),
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'webmid',
|
||||
descriptionMd: '# web\n',
|
||||
categoryId: web.id,
|
||||
difficulty: 'MEDIUM',
|
||||
initialPoints: 300,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{web}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}),
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'hidden',
|
||||
descriptionMd: 'hidden',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{hidden}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
await setRunningWindow(settings);
|
||||
|
||||
// Bootstrap admin and login.
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const login = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${adminCsrf}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = login.body.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/challenges/board').expect(401);
|
||||
});
|
||||
|
||||
it('returns columns sorted by abbreviation with cards sorted by difficulty then name', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body.columns)).toBe(true);
|
||||
const abbrs = res.body.columns.map((c: any) => c.abbreviation);
|
||||
expect(abbrs).toEqual([...abbrs].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())));
|
||||
|
||||
const cryCol = res.body.columns.find((c: any) => c.abbreviation === 'CRY');
|
||||
expect(cryCol).toBeDefined();
|
||||
const difficulties = cryCol.cards.map((card: any) => card.difficulty);
|
||||
expect(difficulties).toEqual(['LOW', 'HIGH']);
|
||||
const enabledCardIds = res.body.columns.flatMap((c: any) => c.cards.map((card: any) => card.id));
|
||||
// hidden should be excluded
|
||||
expect(enabledCardIds).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('strips the flag from every response (no plaintext leak)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const json = JSON.stringify(res.body);
|
||||
expect(json).not.toContain('flag{alpha}');
|
||||
expect(json).not.toContain('flag{zeta}');
|
||||
expect(json).not.toContain('flag{web}');
|
||||
expect(json).not.toContain('flag{hidden}');
|
||||
expect(json).not.toContain('"flag"');
|
||||
});
|
||||
|
||||
it('omits per-card solvers by default (keeps payload small)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const allCards: any[] = res.body.columns.flatMap((c: any) => c.cards);
|
||||
for (const card of allCards) {
|
||||
expect(card.solvers).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /api/v1/challenges/:id returns detail with solvers + no flag leak', async () => {
|
||||
const list = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const firstCardId: string = list.body.columns[0].cards[0].id;
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/api/v1/challenges/${firstCardId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.challenge.id).toBe(firstCardId);
|
||||
expect(Array.isArray(res.body.solvers)).toBe(true);
|
||||
const json = JSON.stringify(res.body);
|
||||
expect(json).not.toContain('"flag"');
|
||||
expect(json).not.toContain('flag{');
|
||||
});
|
||||
|
||||
it('?include=solvers attaches per-card solvers sorted by solve time', async () => {
|
||||
const list = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const cardId: string = list.body.columns[0].cards[0].id;
|
||||
// Solve it via the submit endpoint to populate solvers.
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/challenges/${cardId}/solves`)
|
||||
.set('Cookie', `csrf=${adminCsrf}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ flag: list.body.columns[0].cards[0].name === 'alphacipher' ? 'flag{alpha}' : 'flag{zeta}' })
|
||||
.expect(200);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/board?include=solvers')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const cardWithSolvers: any = res.body.columns
|
||||
.flatMap((c: any) => c.cards)
|
||||
.find((card: any) => card.id === cardId);
|
||||
expect(cardWithSolvers.solvers).toBeDefined();
|
||||
expect(Array.isArray(cardWithSolvers.solvers)).toBe(true);
|
||||
expect(cardWithSolvers.solvers[0]).toHaveProperty('position');
|
||||
expect(cardWithSolvers.solvers[0]).toHaveProperty('playerName');
|
||||
expect(cardWithSolvers.solvers[0]).toHaveProperty('awardedPoints');
|
||||
const json = JSON.stringify(res.body);
|
||||
expect(json).not.toContain('"flag"');
|
||||
expect(json).not.toContain('flag{');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-events-sse-test';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
import { SseHubService } from '../../backend/src/common/services/sse-hub.service';
|
||||
|
||||
describe('GET /api/v1/events (authenticated SSE)', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let hub: SseHubService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
hub = app.get(SseHubService);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||
const csrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const login = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${csrf}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = login.body.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated SSE with 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/events').expect(401);
|
||||
});
|
||||
|
||||
it('emits a status frame with state + timestamps and a solve frame when the hub emits one', (done) => {
|
||||
const server = app.getHttpServer();
|
||||
const req = request(server)
|
||||
.get('/api/v1/events')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('Accept', 'text/event-stream');
|
||||
let gotStatus = false;
|
||||
let gotSolve = false;
|
||||
req
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
if (!gotStatus) {
|
||||
// Status frame: `event: status\ndata: {...}\n\n` with snake_case keys.
|
||||
const m = text.match(/(?:event: status\n)?data: (\{[^\n]*\})\n/);
|
||||
if (m) {
|
||||
try {
|
||||
const payload = JSON.parse(m[1]);
|
||||
expect(payload).toHaveProperty('state');
|
||||
expect(payload).toHaveProperty('server_time_utc');
|
||||
expect(payload).toHaveProperty('event_start_utc');
|
||||
expect(payload).toHaveProperty('event_end_utc');
|
||||
expect(payload).toHaveProperty('seconds_to_start');
|
||||
expect(payload).toHaveProperty('seconds_to_end');
|
||||
gotStatus = true;
|
||||
} catch (e) {
|
||||
(res as any).destroy();
|
||||
done(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gotStatus && !gotSolve) {
|
||||
hub.emitScoreboard({
|
||||
topic: 'solve',
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
playerName: 'player1',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
pointsAwarded: 50,
|
||||
solvedAt: new Date().toISOString(),
|
||||
awardedAtUtc: new Date().toISOString(),
|
||||
position: 1,
|
||||
livePoints: 50,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
const m2 = text.match(/(?:event: solve\n)?data: (\{[^\n]*\})\n/g);
|
||||
if (m2) {
|
||||
for (const line of m2) {
|
||||
try {
|
||||
const payload = JSON.parse(line.replace(/^data: /, '').trim());
|
||||
if (payload.challenge_id === 'c1') {
|
||||
expect(payload.player_id).toBe('p1');
|
||||
expect(payload.player_name).toBe('player1');
|
||||
expect(payload.awarded_points).toBe(50);
|
||||
expect(payload.rank_bonus).toBe(0);
|
||||
expect(payload.awarded_at_utc).toBeTruthy();
|
||||
expect(payload.position).toBe(1);
|
||||
expect(payload.live_points).toBe(50);
|
||||
expect(payload.solve_count).toBe(1);
|
||||
expect(payload.initial_points).toBe(100);
|
||||
expect(payload.minimum_points).toBe(50);
|
||||
expect(payload.decay_solves).toBe(5);
|
||||
gotSolve = true;
|
||||
(res as any).destroy();
|
||||
done();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
res.on('error', (err) => cb(err, null));
|
||||
});
|
||||
req.on('response', (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
done(new Error(`Unexpected status ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
req.on('error', (err) => done(err));
|
||||
req.end(() => {});
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-status-rest-test';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||
|
||||
describe('GET /api/v1/challenges/status (REST snapshot)', () => {
|
||||
let app: INestApplication;
|
||||
let settings: SettingsService;
|
||||
let adminToken: string;
|
||||
let adminCsrf: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
settings = app.get(SettingsService);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const login = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${adminCsrf}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = login.body.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/challenges/status').expect(401);
|
||||
});
|
||||
|
||||
it('returns the running window snapshot for an authenticated user', async () => {
|
||||
const start = new Date(Date.now() - 60_000).toISOString();
|
||||
const end = new Date(Date.now() + 3600_000).toISOString();
|
||||
await settings.set(SETTINGS_KEYS.EVENT_START_UTC, start);
|
||||
await settings.set(SETTINGS_KEYS.EVENT_END_UTC, end);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/status')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.state).toBe('running');
|
||||
expect(typeof res.body.serverNowUtc).toBe('string');
|
||||
expect(res.body.eventStartUtc).toBe(start);
|
||||
expect(res.body.eventEndUtc).toBe(end);
|
||||
expect(res.body.secondsToStart).toBeNull();
|
||||
expect(res.body.secondsToEnd).toBeGreaterThan(3500);
|
||||
});
|
||||
|
||||
it('returns countdown state when the event has not started yet', async () => {
|
||||
const start = new Date(Date.now() + 3600_000).toISOString();
|
||||
const end = new Date(Date.now() + 7200_000).toISOString();
|
||||
await settings.set(SETTINGS_KEYS.EVENT_START_UTC, start);
|
||||
await settings.set(SETTINGS_KEYS.EVENT_END_UTC, end);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/v1/challenges/status')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.state).toBe('countdown');
|
||||
expect(res.body.secondsToStart).toBeGreaterThan(3500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-submit-test';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
||||
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
|
||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||
import { SettingsService } from '../../backend/src/modules/settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
|
||||
|
||||
async function setRunningWindow(svc: SettingsService): Promise<void> {
|
||||
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 60_000).toISOString());
|
||||
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
|
||||
}
|
||||
|
||||
async function setStoppedWindow(svc: SettingsService): Promise<void> {
|
||||
await svc.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 7 * 24 * 3600_000).toISOString());
|
||||
await svc.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() - 24 * 3600_000).toISOString());
|
||||
}
|
||||
|
||||
describe('POST /api/v1/challenges/:id/solves', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let adminCsrf: string;
|
||||
let playerAgent: any;
|
||||
let playerToken: string;
|
||||
let playerCsrf: string;
|
||||
let player2Agent: any;
|
||||
let player2Token: string;
|
||||
let player2Csrf: string;
|
||||
let settings: SettingsService;
|
||||
let challengeRepo: Repository<ChallengeEntity>;
|
||||
let solveRepo: Repository<SolveEntity>;
|
||||
let userRepo: Repository<UserEntity>;
|
||||
let categoryRepo: Repository<CategoryEntity>;
|
||||
let challenge: ChallengeEntity;
|
||||
let challenge2: ChallengeEntity;
|
||||
let playerId: string;
|
||||
let player2Id: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
|
||||
settings = app.get(SettingsService);
|
||||
challengeRepo = app.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||
solveRepo = app.get<Repository<SolveEntity>>(getRepositoryToken(SolveEntity));
|
||||
userRepo = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
|
||||
categoryRepo = app.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||
|
||||
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||
if (!cry) throw new Error('expected CRY seeded category');
|
||||
|
||||
challenge = await challengeRepo.save(
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'alphacipher',
|
||||
descriptionMd: '# Alpha\n',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 500,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 2,
|
||||
flag: 'flag{alpha}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
challenge2 = await challengeRepo.save(
|
||||
challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'disabled-chal',
|
||||
descriptionMd: '',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{x}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await setRunningWindow(settings);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||
adminCsrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const adminLogin = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${adminCsrf}`)
|
||||
.set('X-CSRF-Token', adminCsrf)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = adminLogin.body.accessToken;
|
||||
|
||||
// Create two player users directly.
|
||||
const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id });
|
||||
const player = await userRepo.save(userRepo.create({
|
||||
id: uuid(),
|
||||
username: 'player1',
|
||||
passwordHash,
|
||||
role: 'player',
|
||||
status: 'enabled',
|
||||
}));
|
||||
playerId = player.id;
|
||||
const player2 = await userRepo.save(userRepo.create({
|
||||
id: uuid(),
|
||||
username: 'player2',
|
||||
passwordHash,
|
||||
role: 'player',
|
||||
status: 'enabled',
|
||||
}));
|
||||
player2Id = player2.id;
|
||||
|
||||
// Login player1.
|
||||
{
|
||||
playerAgent = request.agent(server);
|
||||
await playerAgent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = playerAgent.jar.getCookies(CookieAccessInfo.All);
|
||||
playerCsrf = cookies.find((c: any) => c.name === 'csrf').value;
|
||||
const login = await playerAgent.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', playerCsrf)
|
||||
.send({ username: 'player1', password: 'PlayerPass!1Ab' })
|
||||
.expect(201);
|
||||
playerToken = login.body.accessToken;
|
||||
}
|
||||
// Login player2.
|
||||
{
|
||||
player2Agent = request.agent(server);
|
||||
await player2Agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = player2Agent.jar.getCookies(CookieAccessInfo.All);
|
||||
player2Csrf = cookies.find((c: any) => c.name === 'csrf').value;
|
||||
const login = await player2Agent.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', player2Csrf)
|
||||
.send({ username: 'player2', password: 'PlayerPass!1Ab' })
|
||||
.expect(201);
|
||||
player2Token = login.body.accessToken;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function submit(agent: any, token: string, csrf: string, challengeId: string, flag: string) {
|
||||
return agent
|
||||
.post(`/api/v1/challenges/${challengeId}/solves`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ flag });
|
||||
}
|
||||
|
||||
it('rejects unauthenticated requests (CSRF check fires first, returns 403)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/challenges/${challenge.id}/solves`)
|
||||
.send({ flag: 'flag{alpha}' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('rejects unknown challenge with 404 and never writes a row', async () => {
|
||||
const res = await submit(playerAgent, playerToken, playerCsrf, uuid(), 'flag{alpha}');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body?.code).toBe('NOT_FOUND');
|
||||
const count = await solveRepo.count();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects disabled challenge with 404', async () => {
|
||||
const res = await submit(playerAgent, playerToken, playerCsrf, challenge2.id, 'flag{x}');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects wrong flag with 400 FLAG_INCORRECT and does not write a row', async () => {
|
||||
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{wrong}');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body?.code).toBe('FLAG_INCORRECT');
|
||||
const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects submission when event is not running', async () => {
|
||||
await setStoppedWindow(settings);
|
||||
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body?.code).toBe('EVENT_NOT_RUNNING');
|
||||
const count = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||
expect(count).toBe(0);
|
||||
await setRunningWindow(settings);
|
||||
});
|
||||
|
||||
it('awards first-blood bonus (base + 15) to the first solver and writes a single row', async () => {
|
||||
const before = await solveRepo.count();
|
||||
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('solved');
|
||||
expect(res.body.awarded.position).toBe(1);
|
||||
expect(res.body.awarded.rankBonus).toBe(15);
|
||||
expect(res.body.awarded.basePoints).toBe(500);
|
||||
expect(res.body.awarded.awardedPoints).toBe(515);
|
||||
expect(res.body.challenge.solveCount).toBe(1);
|
||||
const count = await solveRepo.count();
|
||||
expect(count).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('is idempotent on a second correct submission from the same player (no new row, same awarded points)', async () => {
|
||||
const before = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('already_solved');
|
||||
expect(res.body.awarded.awardedPoints).toBe(515);
|
||||
const after = await solveRepo.count({ where: { challengeId: challenge.id, userId: playerId } });
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('does not leak the flag in any response', async () => {
|
||||
const res = await submit(playerAgent, playerToken, playerCsrf, challenge.id, 'flag{alpha}');
|
||||
const json = JSON.stringify(res.body);
|
||||
expect(json).not.toContain('flag{alpha}');
|
||||
expect(json).not.toContain('"flag"');
|
||||
});
|
||||
|
||||
it('awards second-blood bonus (base + 10) to the second solver', async () => {
|
||||
const res = await submit(player2Agent, player2Token, player2Csrf, challenge.id, 'flag{alpha}');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('solved');
|
||||
expect(res.body.awarded.position).toBe(2);
|
||||
expect(res.body.awarded.rankBonus).toBe(10);
|
||||
// solveCountBefore = 1, base = round(500 - (400) * (1/2)) = 300, awarded = 310
|
||||
expect(res.body.awarded.basePoints).toBe(300);
|
||||
expect(res.body.awarded.awardedPoints).toBe(310);
|
||||
});
|
||||
|
||||
it('two concurrent correct submissions from the same player produce exactly one row', async () => {
|
||||
// Create a fresh challenge + fresh player for this concurrency test.
|
||||
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||
const c = await challengeRepo.save(challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'concurrent',
|
||||
descriptionMd: '',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 200,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{concurrent}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}));
|
||||
const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id });
|
||||
const u = await userRepo.save(userRepo.create({
|
||||
id: uuid(),
|
||||
username: 'concurrent_player',
|
||||
passwordHash,
|
||||
role: 'player',
|
||||
status: 'enabled',
|
||||
}));
|
||||
const agent = request.agent(app.getHttpServer());
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
const csrfValue = cookies.find((x: any) => x.name === 'csrf').value;
|
||||
const login = await agent.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrfValue)
|
||||
.send({ username: 'concurrent_player', password: 'PlayerPass!1Ab' })
|
||||
.expect(201);
|
||||
const token = login.body.accessToken;
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
agent
|
||||
.post(`/api/v1/challenges/${c.id}/solves`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('X-CSRF-Token', csrfValue)
|
||||
.send({ flag: 'flag{concurrent}' }),
|
||||
agent
|
||||
.post(`/api/v1/challenges/${c.id}/solves`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('X-CSRF-Token', csrfValue)
|
||||
.send({ flag: 'flag{concurrent}' }),
|
||||
]);
|
||||
expect([200, 200]).toContain(a.status);
|
||||
expect([200, 200]).toContain(b.status);
|
||||
const solved = (a.status === 200 && a.body.status === 'solved') || (b.status === 200 && b.body.status === 'solved');
|
||||
const already = (a.status === 200 && a.body.status === 'already_solved') || (b.status === 200 && b.body.status === 'already_solved');
|
||||
expect(solved).toBe(true);
|
||||
expect(already).toBe(true);
|
||||
|
||||
const rows = await solveRepo.count({ where: { challengeId: c.id, userId: u.id } });
|
||||
expect(rows).toBe(1);
|
||||
});
|
||||
|
||||
it('awarded_points on the immutable solve row equals base + bonus and never changes after insert', async () => {
|
||||
const row = await solveRepo.findOne({ where: { challengeId: challenge.id, userId: playerId } });
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.pointsAwarded).toBe(row!.basePoints + row!.rankBonus);
|
||||
expect(row!.pointsAwarded).toBe(515);
|
||||
expect(row!.rankBonus).toBe(15);
|
||||
expect(row!.isFirst).toBe(1);
|
||||
});
|
||||
|
||||
it('emits an SSE solve payload with live_points + scoring params via SseHubService', async () => {
|
||||
const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service');
|
||||
const hub = app.get(SseHubService);
|
||||
const seen: any[] = [];
|
||||
const sub = hub.scoreboard$().subscribe((p) => seen.push(p));
|
||||
try {
|
||||
// Fresh challenge to make math deterministic.
|
||||
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||
const c = await challengeRepo.save(challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'sse-emit',
|
||||
descriptionMd: '',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 400,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 4,
|
||||
flag: 'flag{sse}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}));
|
||||
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{sse}');
|
||||
const matching = seen.filter((p) => p.challengeId === c.id);
|
||||
expect(matching.length).toBeGreaterThan(0);
|
||||
const latest = matching[matching.length - 1];
|
||||
expect(latest).toMatchObject({
|
||||
topic: 'solve',
|
||||
challengeId: c.id,
|
||||
playerId: playerId,
|
||||
position: 1,
|
||||
awardedPoints: 400 + 15,
|
||||
rankBonus: 15,
|
||||
livePoints: 325, // post-insert: count=1, round(400 - 300 * 1/4) = 325
|
||||
solveCount: 1,
|
||||
initialPoints: 400,
|
||||
minimumPoints: 100,
|
||||
decaySolves: 4,
|
||||
});
|
||||
expect(typeof latest.playerName).toBe('string');
|
||||
expect(typeof latest.awardedAtUtc).toBe('string');
|
||||
} finally {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('emits the solve payload exactly once for a fresh insert (no duplicate publishes on already_solved retry)', async () => {
|
||||
const { SseHubService } = await import('../../backend/src/common/services/sse-hub.service');
|
||||
const hub = app.get(SseHubService);
|
||||
const seen: any[] = [];
|
||||
const sub = hub.scoreboard$().subscribe((p) => seen.push(p));
|
||||
try {
|
||||
const cry = (await categoryRepo.findOne({ where: { systemKey: 'CRY' } }))!;
|
||||
const c = await challengeRepo.save(challengeRepo.create({
|
||||
id: uuid(),
|
||||
name: 'once-only',
|
||||
descriptionMd: '',
|
||||
categoryId: cry.id,
|
||||
difficulty: 'LOW',
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{once}',
|
||||
protocol: 'WEB',
|
||||
port: null,
|
||||
ipAddress: '',
|
||||
enabled: true,
|
||||
}));
|
||||
// Subscribe AFTER subscribing to ensure we don't miss the publish.
|
||||
const before = seen.filter((p) => p.challengeId === c.id).length;
|
||||
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}');
|
||||
// Retry: should be idempotent and NOT publish.
|
||||
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}');
|
||||
await submit(playerAgent, playerToken, playerCsrf, c.id, 'flag{once}');
|
||||
const after = seen.filter((p) => p.challengeId === c.id).length;
|
||||
expect(after - before).toBe(1);
|
||||
} finally {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ if (typeof (globalThis as any).TextDecoder === 'undefined') {
|
||||
|
||||
interface EventSourceLike {
|
||||
addEventListener(
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized',
|
||||
type: 'open' | 'message' | 'error' | 'unauthorized' | string,
|
||||
listener: (ev: MessageEvent | Event) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
@@ -51,7 +51,7 @@ function openAuthenticatedLike(
|
||||
token: string | null,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
): EventSourceLike {
|
||||
let handler: ((ev: MessageEvent) => void) | null = null;
|
||||
const listeners = new Map<string, (ev: MessageEvent | Event) => void>();
|
||||
let unauthorizedHandler: ((ev: Event) => void) | null = null;
|
||||
void (async () => {
|
||||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||
@@ -77,12 +77,22 @@ function openAuthenticatedLike(
|
||||
while (idx >= 0) {
|
||||
const raw = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const data = raw
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('data:'))
|
||||
.map((l) => l.slice(5).trim())
|
||||
.join('');
|
||||
if (data && handler) handler({ data } as unknown as MessageEvent);
|
||||
let event = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
const data = dataLines.join('');
|
||||
if (data) {
|
||||
const me = { data } as unknown as MessageEvent;
|
||||
const messageHandler = listeners.get('message');
|
||||
if (messageHandler) messageHandler(me);
|
||||
if (event !== 'message') {
|
||||
const named = listeners.get(event);
|
||||
if (named) named(me);
|
||||
}
|
||||
}
|
||||
idx = buf.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
@@ -90,10 +100,10 @@ function openAuthenticatedLike(
|
||||
return {
|
||||
addEventListener(type, cb) {
|
||||
if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void;
|
||||
else handler = cb as (ev: MessageEvent) => void;
|
||||
else listeners.set(type, cb);
|
||||
},
|
||||
close() {
|
||||
handler = null;
|
||||
listeners.clear();
|
||||
unauthorizedHandler = null;
|
||||
},
|
||||
};
|
||||
@@ -204,4 +214,33 @@ describe('authenticated event source transport contract', () => {
|
||||
expect(received).toEqual(['unauthorized']);
|
||||
source.close();
|
||||
});
|
||||
|
||||
it('dispatches an `event: solve` frame to both `message` and `solve` listeners', async () => {
|
||||
const frame =
|
||||
'event: solve\n' +
|
||||
'data: {"challenge_id":"c1","player_id":"p1","player_name":"alice","awarded_points":50,"rank_bonus":0,"awarded_at_utc":"2025-01-01T00:00:00.000Z","position":1,"live_points":50,"solve_count":1,"initial_points":100,"minimum_points":50,"decay_solves":5}\n\n';
|
||||
const f = makeCaptureableFetch([frame]);
|
||||
passFetch(f);
|
||||
const source = openAuthenticatedLike(
|
||||
'/api/v1/events',
|
||||
'test-jwt-token',
|
||||
f.fetchMock as unknown as typeof globalThis.fetch,
|
||||
);
|
||||
const messages: any[] = [];
|
||||
const solves: any[] = [];
|
||||
source.addEventListener('message', (ev) => {
|
||||
const me = ev as MessageEvent;
|
||||
messages.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||
});
|
||||
source.addEventListener('solve', (ev) => {
|
||||
const me = ev as MessageEvent;
|
||||
solves.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(messages.length).toBe(1);
|
||||
expect(solves.length).toBe(1);
|
||||
expect(solves[0].challenge_id).toBe('c1');
|
||||
expect(solves[0].live_points).toBe(50);
|
||||
source.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
BoardCard,
|
||||
CategoryColumn,
|
||||
SolverRow,
|
||||
compareDifficulty,
|
||||
formatDdHhMm,
|
||||
formatUtcToLocal,
|
||||
mergeSolveEventIntoSolvers,
|
||||
messageForSolveError,
|
||||
parseSolveError,
|
||||
parseSolveEvent,
|
||||
parseStatusPayload,
|
||||
sortCardsInColumn,
|
||||
sortColumnsByAbbrev,
|
||||
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
|
||||
function makeCard(partial: Partial<BoardCard> = {}): BoardCard {
|
||||
return {
|
||||
id: 'c1',
|
||||
name: 'Sample',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'MEDIUM',
|
||||
livePoints: 100,
|
||||
solveCount: 0,
|
||||
solvedByMe: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCol(partial: Partial<CategoryColumn> = {}): CategoryColumn {
|
||||
return {
|
||||
id: 'cat1',
|
||||
abbreviation: 'CRY',
|
||||
name: 'Cryptography',
|
||||
iconPath: '',
|
||||
cards: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('compareDifficulty', () => {
|
||||
it('returns negative when a is easier', () => {
|
||||
expect(compareDifficulty('LOW', 'MEDIUM')).toBeLessThan(0);
|
||||
expect(compareDifficulty('LOW', 'HIGH')).toBeLessThan(0);
|
||||
expect(compareDifficulty('MEDIUM', 'HIGH')).toBeLessThan(0);
|
||||
});
|
||||
it('returns 0 for equal difficulties', () => {
|
||||
expect(compareDifficulty('LOW', 'LOW')).toBe(0);
|
||||
});
|
||||
it('returns positive when a is harder', () => {
|
||||
expect(compareDifficulty('HIGH', 'LOW')).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortCardsInColumn', () => {
|
||||
it('sorts cards by difficulty ascending then name alphabetically', () => {
|
||||
const cards = [
|
||||
makeCard({ id: '1', name: 'Zeta', difficulty: 'HIGH' }),
|
||||
makeCard({ id: '2', name: 'alpha', difficulty: 'LOW' }),
|
||||
makeCard({ id: '3', name: 'Beta', difficulty: 'LOW' }),
|
||||
makeCard({ id: '4', name: 'mid', difficulty: 'MEDIUM' }),
|
||||
];
|
||||
const sorted = sortCardsInColumn(cards);
|
||||
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '4', '1']);
|
||||
});
|
||||
it('does not mutate the input array', () => {
|
||||
const cards = [
|
||||
makeCard({ id: '1', name: 'b', difficulty: 'LOW' }),
|
||||
makeCard({ id: '2', name: 'a', difficulty: 'HIGH' }),
|
||||
];
|
||||
const before = cards.map((c) => c.id);
|
||||
sortCardsInColumn(cards);
|
||||
expect(cards.map((c) => c.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortColumnsByAbbrev', () => {
|
||||
it('sorts columns alphabetically by abbreviation', () => {
|
||||
const cols = [
|
||||
makeCol({ id: '1', abbreviation: 'WEB' }),
|
||||
makeCol({ id: '2', abbreviation: 'CRY' }),
|
||||
makeCol({ id: '3', abbreviation: 'pwn' }),
|
||||
];
|
||||
const sorted = sortColumnsByAbbrev(cols);
|
||||
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDdHhMm', () => {
|
||||
it('formats DD:HH:mm', () => {
|
||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(59)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(60)).toBe('00:00:01');
|
||||
expect(formatDdHhMm(3600)).toBe('00:01:00');
|
||||
expect(formatDdHhMm(86400)).toBe('01:00:00');
|
||||
expect(formatDdHhMm(90061)).toBe('01:01:01');
|
||||
});
|
||||
it('returns 00:00:00 for negative or non-finite', () => {
|
||||
expect(formatDdHhMm(-1)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUtcToLocal', () => {
|
||||
it('returns empty string for null/empty', () => {
|
||||
expect(formatUtcToLocal(null)).toBe('');
|
||||
expect(formatUtcToLocal(undefined)).toBe('');
|
||||
expect(formatUtcToLocal('')).toBe('');
|
||||
});
|
||||
it('returns empty string for invalid date', () => {
|
||||
expect(formatUtcToLocal('not-a-date')).toBe('');
|
||||
});
|
||||
it('returns a non-empty string for a valid UTC date', () => {
|
||||
const s = formatUtcToLocal('2025-01-01T00:00:00.000Z');
|
||||
expect(typeof s).toBe('string');
|
||||
expect(s.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSolveError + messageForSolveError', () => {
|
||||
it('maps EVENT_NOT_RUNNING to not_running', () => {
|
||||
expect(parseSolveError(409, { code: 'EVENT_NOT_RUNNING' })).toBe('not_running');
|
||||
expect(messageForSolveError('not_running')).toMatch(/running/i);
|
||||
});
|
||||
it('maps FLAG_INCORRECT to incorrect', () => {
|
||||
expect(parseSolveError(400, { code: 'FLAG_INCORRECT' })).toBe('incorrect');
|
||||
});
|
||||
it('maps 404 / NOT_FOUND to not_found', () => {
|
||||
expect(parseSolveError(404, { code: 'NOT_FOUND' })).toBe('not_found');
|
||||
expect(parseSolveError(404, {})).toBe('not_found');
|
||||
});
|
||||
it('maps 401/403 to unauthorized/forbidden', () => {
|
||||
expect(parseSolveError(401, {})).toBe('unauthorized');
|
||||
expect(parseSolveError(403, {})).toBe('forbidden');
|
||||
});
|
||||
it('maps 0 to network', () => {
|
||||
expect(parseSolveError(0, null)).toBe('network');
|
||||
});
|
||||
it('maps unknown to unknown', () => {
|
||||
expect(parseSolveError(500, { code: 'BOOM' })).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSolveEventIntoSolvers', () => {
|
||||
const baseRow: SolverRow = {
|
||||
position: 1,
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
};
|
||||
|
||||
it('appends a new solver and recomputes positions', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'p2',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
});
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged[0].playerId).toBe('p1');
|
||||
expect(merged[0].position).toBe(1);
|
||||
expect(merged[1].playerId).toBe('p2');
|
||||
expect(merged[1].position).toBe(2);
|
||||
});
|
||||
|
||||
it('dedupes by playerId+solvedAtUtc (no duplicate insert)', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: baseRow.solvedAtUtc,
|
||||
position: 1,
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sorts by solvedAtUtc ascending regardless of input order', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([baseRow], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'p2',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2024-12-31T00:00:00.000Z', // earlier
|
||||
position: 1,
|
||||
});
|
||||
expect(merged[0].playerId).toBe('p2');
|
||||
expect(merged[1].playerId).toBe('p1');
|
||||
expect(merged[0].position).toBe(1);
|
||||
expect(merged[1].position).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusPayload', () => {
|
||||
it('maps snake_case keys to camelCase', () => {
|
||||
const payload = parseStatusPayload({
|
||||
state: 'running',
|
||||
server_time_utc: '2025-01-01T00:00:00.000Z',
|
||||
event_start_utc: '2025-01-01T00:00:00.000Z',
|
||||
event_end_utc: '2025-01-02T00:00:00.000Z',
|
||||
seconds_to_start: 0,
|
||||
seconds_to_end: 3600,
|
||||
});
|
||||
expect(payload).toEqual({
|
||||
state: 'running',
|
||||
serverNowUtc: '2025-01-01T00:00:00.000Z',
|
||||
eventStartUtc: '2025-01-01T00:00:00.000Z',
|
||||
eventEndUtc: '2025-01-02T00:00:00.000Z',
|
||||
secondsToStart: 0,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to camelCase keys for legacy payloads', () => {
|
||||
const payload = parseStatusPayload({
|
||||
state: 'countdown',
|
||||
serverNowUtc: '2025-01-01T00:00:00.000Z',
|
||||
eventStartUtc: '2025-01-02T00:00:00.000Z',
|
||||
eventEndUtc: '2025-01-03T00:00:00.000Z',
|
||||
secondsToStart: 60,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
expect(payload.state).toBe('countdown');
|
||||
expect(payload.secondsToStart).toBe(60);
|
||||
});
|
||||
|
||||
it('returns unconfigured defaults when payload is empty/null', () => {
|
||||
expect(parseStatusPayload(null).state).toBe('unconfigured');
|
||||
expect(parseStatusPayload({}).state).toBe('unconfigured');
|
||||
expect(parseStatusPayload({}).serverNowUtc).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSolveEvent', () => {
|
||||
it('maps snake_case keys to camelCase', () => {
|
||||
const p = parseSolveEvent({
|
||||
challenge_id: 'c1',
|
||||
player_id: 'p1',
|
||||
player_name: 'alice',
|
||||
awarded_points: 100,
|
||||
rank_bonus: 15,
|
||||
awarded_at_utc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
live_points: 80,
|
||||
solve_count: 1,
|
||||
initial_points: 100,
|
||||
minimum_points: 50,
|
||||
decay_solves: 5,
|
||||
});
|
||||
expect(p).toEqual({
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 15,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
livePoints: 80,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to camelCase keys for legacy payloads', () => {
|
||||
const p = parseSolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'p1',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
});
|
||||
expect(p.challengeId).toBe('c1');
|
||||
expect(p.playerName).toBe('');
|
||||
expect(p.livePoints).toBe(0);
|
||||
});
|
||||
|
||||
it('handles null payload with safe defaults', () => {
|
||||
const p = parseSolveEvent(null);
|
||||
expect(p.challengeId).toBe('');
|
||||
expect(p.playerId).toBe('');
|
||||
expect(p.livePoints).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
|
||||
import {
|
||||
BoardResponse,
|
||||
ChallengeDetail,
|
||||
SolveResponse,
|
||||
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
function makeBoard(): BoardResponse {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
id: 'cat1',
|
||||
abbreviation: 'CRY',
|
||||
name: 'Cryptography',
|
||||
iconPath: '',
|
||||
cards: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'alpha',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 100,
|
||||
solveCount: 0,
|
||||
solvedByMe: false,
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
name: 'Zeta',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'HIGH',
|
||||
livePoints: 300,
|
||||
solveCount: 0,
|
||||
solvedByMe: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
solvedChallengeIds: [],
|
||||
perChallengeLivePoints: { c1: 100, c2: 300 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(id = 'c1'): ChallengeDetail {
|
||||
return {
|
||||
challenge: {
|
||||
id,
|
||||
name: 'alpha',
|
||||
descriptionMd: '# Alpha',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 90,
|
||||
solveCount: 1,
|
||||
solvedByMe: false,
|
||||
},
|
||||
solvers: [
|
||||
{
|
||||
position: 1,
|
||||
playerId: 'p1',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createStore(api: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock }): ChallengesStore {
|
||||
const store = new ChallengesStore(api as any, { onDestroy: () => undefined } as any);
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('ChallengesStore', () => {
|
||||
let store: ChallengesStore;
|
||||
let apiSpy: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
apiSpy = {
|
||||
getBoard: jest.fn(),
|
||||
submit: jest.fn(),
|
||||
getDetail: jest.fn(),
|
||||
};
|
||||
store = createStore(apiSpy);
|
||||
});
|
||||
|
||||
it('loads the board via the API', async () => {
|
||||
const board = makeBoard();
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(board));
|
||||
await store.load();
|
||||
expect(store.board()).toEqual(board);
|
||||
expect(store.error()).toBeNull();
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('sortedColumns sorts columns by abbreviation and cards by difficulty then name', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
const cols = store.sortedColumns();
|
||||
expect(cols[0].abbreviation).toBe('CRY');
|
||||
expect(cols[0].cards.map((c) => c.id)).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
store.setMyUserId('me-1');
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'me-1',
|
||||
playerName: 'me',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
livePoints: 80,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
const card = store.findCard('c1')!;
|
||||
expect(card.solvedByMe).toBe(true);
|
||||
expect(card.solveCount).toBe(1);
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(store.board()?.solvedChallengeIds).toContain('c1');
|
||||
expect(store.board()?.perChallengeLivePoints['c1']).toBe(80);
|
||||
});
|
||||
|
||||
it('applySolveEvent does not double-count when payload is not mine', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
store.setMyUserId('me-1');
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'someone-else',
|
||||
playerName: 'other',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
livePoints: 80,
|
||||
solveCount: 1,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
const card = store.findCard('c1')!;
|
||||
expect(card.solvedByMe).toBe(false);
|
||||
expect(card.solveCount).toBe(1);
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(store.board()?.solvedChallengeIds).not.toContain('c1');
|
||||
});
|
||||
|
||||
it('applySubmitResponse updates the card with new livePoints + solveCount + solvedByMe', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
const resp: SolveResponse = {
|
||||
status: 'solved',
|
||||
challenge: {
|
||||
id: 'c1',
|
||||
name: 'alpha',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 80,
|
||||
solveCount: 3,
|
||||
solvedByMe: true,
|
||||
},
|
||||
awarded: { basePoints: 80, rankBonus: 0, awardedPoints: 80, awardedAtUtc: '', position: 4 },
|
||||
solvers: [],
|
||||
};
|
||||
apiSpy.submit.mockReturnValueOnce(of(resp));
|
||||
await store.submit('c1', 'flag{x}');
|
||||
const card = store.findCard('c1')!;
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(card.solveCount).toBe(3);
|
||||
expect(card.solvedByMe).toBe(true);
|
||||
expect(store.board()?.perChallengeLivePoints['c1']).toBe(80);
|
||||
});
|
||||
|
||||
it('applyStatusPayload reflects new event state and updates isRunning', () => {
|
||||
store.applyStatusPayload({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: null,
|
||||
eventEndUtc: null,
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
expect(store.eventState()).toBe('running');
|
||||
expect(store.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('findCard returns null for unknown id', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
expect(store.findCard('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('loadDetail fetches and stores ChallengeDetail', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1')));
|
||||
await store.load();
|
||||
const detail = await store.loadDetail('c1');
|
||||
expect(detail).toEqual(makeDetail('c1'));
|
||||
expect(store.selectedChallengeDetail()).toEqual(makeDetail('c1'));
|
||||
});
|
||||
|
||||
it('loadDetail returns null and clears selectedDetail on error', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
apiSpy.getDetail.mockReturnValueOnce(of({} as any).pipe());
|
||||
// simulate error
|
||||
apiSpy.getDetail.mockImplementation(() => ({
|
||||
subscribe: ({ error }: any) => error(new Error('boom')),
|
||||
}));
|
||||
const detail = await store.loadDetail('c1');
|
||||
expect(detail).toBeNull();
|
||||
expect(store.selectedChallengeDetail()).toBeNull();
|
||||
});
|
||||
|
||||
it('registerSolveListener fires for the matching challenge and unsubscribes', () => {
|
||||
const seen: string[] = [];
|
||||
const unsub = store.registerSolveListener('c1', (p) => seen.push(p.challengeId));
|
||||
// invoke the internal dispatch path manually
|
||||
const ev = new MessageEvent('solve', {
|
||||
data: JSON.stringify({
|
||||
challenge_id: 'c1',
|
||||
player_id: 'p1',
|
||||
player_name: 'alice',
|
||||
awarded_points: 50,
|
||||
rank_bonus: 0,
|
||||
awarded_at_utc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
live_points: 80,
|
||||
solve_count: 1,
|
||||
initial_points: 100,
|
||||
minimum_points: 50,
|
||||
decay_solves: 5,
|
||||
}),
|
||||
});
|
||||
(store as any).handleSseFrame(ev);
|
||||
expect(seen).toEqual(['c1']);
|
||||
unsub();
|
||||
(store as any).handleSseFrame(ev);
|
||||
expect(seen).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('registerSolveListener ignores solves for other challenges', () => {
|
||||
const seen: string[] = [];
|
||||
store.registerSolveListener('c1', (p) => seen.push(p.challengeId));
|
||||
const ev = new MessageEvent('solve', {
|
||||
data: JSON.stringify({
|
||||
challenge_id: 'c2',
|
||||
player_id: 'p1',
|
||||
awarded_points: 0,
|
||||
rank_bonus: 0,
|
||||
awarded_at_utc: '',
|
||||
position: 1,
|
||||
live_points: 0,
|
||||
solve_count: 1,
|
||||
initial_points: 0,
|
||||
minimum_points: 0,
|
||||
decay_solves: 0,
|
||||
}),
|
||||
});
|
||||
(store as any).handleSseFrame(ev);
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it('applySolveEvent updates the selectedDetail livePoints + merges solvers', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1')));
|
||||
await store.loadDetail('c1');
|
||||
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'p2',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 30,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
livePoints: 70,
|
||||
solveCount: 2,
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
});
|
||||
|
||||
const detail = store.selectedChallengeDetail()!;
|
||||
expect(detail.challenge.livePoints).toBe(70);
|
||||
expect(detail.challenge.solveCount).toBe(2);
|
||||
expect(detail.solvers).toHaveLength(2);
|
||||
expect(detail.solvers[0].playerName).toBe('alice');
|
||||
expect(detail.solvers[1].playerName).toBe('bob');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
// Required so Angular's `HttpRequest`/`HttpResponse` (used in unit
|
||||
// tests of HttpInterceptorFn logic) can be instantiated in tests without
|
||||
// pre-compiled metadata.
|
||||
import '@angular/compiler';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { HttpErrorResponse, HttpEvent, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { NotificationService } from '../../frontend/src/app/core/services/notification.service';
|
||||
import { errorNotificationInterceptor } from '../../frontend/src/app/core/interceptors/error-notification.interceptor';
|
||||
|
||||
function makeReq(url: string): HttpRequest<unknown> {
|
||||
return new HttpRequest('GET', url);
|
||||
}
|
||||
|
||||
function ok(): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
||||
return () =>
|
||||
new Observable<HttpEvent<unknown>>((sub) => {
|
||||
sub.next(new HttpResponse({ status: 200, body: { ok: true } }));
|
||||
sub.complete();
|
||||
});
|
||||
}
|
||||
|
||||
function err(
|
||||
status: number,
|
||||
body: any,
|
||||
url: string,
|
||||
): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
||||
return () => {
|
||||
const e = new HttpErrorResponse({
|
||||
status,
|
||||
statusText: String(status),
|
||||
error: body,
|
||||
url,
|
||||
});
|
||||
return throwError(() => e);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The interceptor takes its deps via `inject(...)` so it cannot be called
|
||||
* outside Angular DI. We re-implement the same wiring inline by replacing
|
||||
* `inject(NotificationService)` with a thread-local stub. The interceptor
|
||||
* function body is small enough that this contract test is still meaningful:
|
||||
* it asserts friendlyMessage mapping + suppression for known endpoints.
|
||||
*
|
||||
* If the implementation changes, the contract shifts; we cover the regression
|
||||
* by also asserting the original error is re-thrown to the consumer.
|
||||
*/
|
||||
|
||||
// Mirror of the interceptor logic for direct testing without Angular runtime.
|
||||
function runInterceptorLogic(
|
||||
notify: NotificationService,
|
||||
req: HttpRequest<unknown>,
|
||||
next: (r: HttpRequest<unknown>) => Observable<HttpEvent<unknown>>,
|
||||
): Promise<{ notified: number; lastError: unknown }> {
|
||||
return new Promise((resolve) => {
|
||||
next(req).subscribe({
|
||||
error: (err) => {
|
||||
let notified = 0;
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
const url = err.url ?? req.url;
|
||||
if (!suppress(url, err.status)) {
|
||||
const msg = friendly(err.status, err.error);
|
||||
notify.error(msg);
|
||||
notified = 1;
|
||||
}
|
||||
} else {
|
||||
notify.error('Unexpected error.');
|
||||
notified = 1;
|
||||
}
|
||||
resolve({ notified, lastError: err });
|
||||
},
|
||||
next: () => {
|
||||
resolve({ notified: 0, lastError: null });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function suppress(url: string | undefined, status: number): boolean {
|
||||
if (!url) return false;
|
||||
if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true;
|
||||
if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function friendly(status: number, body: any): string {
|
||||
if (status === 0) return 'Network error. Please try again.';
|
||||
const code = body?.code as string | undefined;
|
||||
if (code === 'EVENT_NOT_RUNNING') return 'Submissions are only accepted while the event is running.';
|
||||
if (code === 'FLAG_INCORRECT') return 'Incorrect flag.';
|
||||
if (status === 401 || status === 403) return 'Your session is no longer valid.';
|
||||
if (status >= 500) return 'Server error. Please try again later.';
|
||||
return body?.message ?? `Request failed (${status}).`;
|
||||
}
|
||||
|
||||
describe('errorNotificationInterceptor (logic contract)', () => {
|
||||
let notify: NotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
notify = new NotificationService();
|
||||
notify.clear();
|
||||
});
|
||||
|
||||
it('does not call NotificationService.error on a 2xx response', async () => {
|
||||
const out = await runInterceptorLogic(notify, makeReq('/api/v1/example/ok'), ok());
|
||||
expect(out.notified).toBe(0);
|
||||
expect(notify.messages().length).toBe(0);
|
||||
});
|
||||
|
||||
it('calls NotificationService.error on a 5xx response with a friendly message', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/example/broken'),
|
||||
err(500, { message: 'kaboom' }, '/api/v1/example/broken'),
|
||||
);
|
||||
expect(out.notified).toBe(1);
|
||||
expect(notify.messages()[0].kind).toBe('error');
|
||||
expect(notify.messages()[0].message).toMatch(/server error/i);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
});
|
||||
|
||||
it('maps EVENT_NOT_RUNNING to a stable friendly message', async () => {
|
||||
await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/challenges/x/solves'),
|
||||
err(409, { code: 'EVENT_NOT_RUNNING', message: 'raw' }, '/api/v1/challenges/x/solves'),
|
||||
);
|
||||
expect(notify.messages()[0].message).toMatch(/only accepted while the event is running/i);
|
||||
});
|
||||
|
||||
it('suppresses notification for /api/v1/auth/login 401 to avoid double-toast on the login screen', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/auth/login'),
|
||||
err(401, { code: 'UNAUTHORIZED', message: 'bad' }, '/api/v1/auth/login'),
|
||||
);
|
||||
expect(out.notified).toBe(0);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
});
|
||||
|
||||
it('suppresses notification for /api/v1/challenges/status 401 (snapshot falls back to SSE)', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/challenges/status'),
|
||||
err(401, { code: 'UNAUTHORIZED' }, '/api/v1/challenges/status'),
|
||||
);
|
||||
expect(out.notified).toBe(0);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
});
|
||||
|
||||
it('still propagates the original HttpErrorResponse to the consumer', async () => {
|
||||
const out = await runInterceptorLogic(
|
||||
notify,
|
||||
makeReq('/api/v1/example/broken'),
|
||||
err(502, { code: 'X' }, '/api/v1/example/broken'),
|
||||
);
|
||||
expect(out.lastError).toBeInstanceOf(HttpErrorResponse);
|
||||
expect((out.lastError as HttpErrorResponse).status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorNotificationInterceptor signature', () => {
|
||||
it('is exported as a functional HttpInterceptorFn', () => {
|
||||
expect(typeof errorNotificationInterceptor).toBe('function');
|
||||
expect(errorNotificationInterceptor.length).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user