AI Implementation feature(892): Admin Area General Settings and Categories 1.10 #32
@@ -1,42 +0,0 @@
|
||||
# Implementation Plan: Admin Area General Settings and Categories 1.09
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
- **Codebase style & conventions:** Node.js TypeScript monorepo with a NestJS 10 REST backend and Angular 17 standalone SPA. The General page is an `OnPush` standalone component using a non-nullable reactive form, Angular signals/computed state, `@if` template control flow, and `takeUntilDestroyed` subscriptions. Reusable validation and message logic is kept in `frontend/src/app/features/admin/general.pure.ts`; the API validates request bodies at the controller boundary with Zod through `ZodValidationPipe`.
|
||||
- **Data Layer:** TypeORM with `better-sqlite3`; general settings are key/value rows accessed through `SettingsService`. No schema migration is required because `defaultChallengeIp` already exists and only its accepted value contract changes. `AdminGeneralService.updateSettings` persists the Zod-parsed payload, so server-side normalization must happen in `GeneralSettingsSchema` before the service receives it.
|
||||
- **Test Framework & Structure:** Root Jest 29 configuration with separate backend and jsdom frontend projects. Tests live under dedicated `tests/backend` and `tests/frontend` folders and are run together with `npm test` from `/repo`; targeted commands are `npm run test:backend` and `npm run test:frontend`. Existing General Settings schema tests are in `tests/backend/admin-general-service.spec.ts`, while pure Angular form-validation helpers are tested in `tests/frontend/admin-general-pure.spec.ts` without rendering a UI.
|
||||
- **Required Tools & Dependencies:** No new system tools, global CLIs, package dependencies, persistent `/data` assets, or `setup.sh` changes are required. Use the existing Angular Forms APIs, Zod, Jest, TypeScript, and current npm workspace setup. Verification should run `npm test` and `npm run build`; no separate lint/typecheck scripts are defined, and both workspace builds perform the available TypeScript/Angular compilation checks.
|
||||
|
||||
## 2. Impacted Files
|
||||
- **To Modify:**
|
||||
- `backend/src/modules/admin/dto/general.dto.ts` — trim and strictly validate the default challenge address at the API boundary, with a field-specific validation message.
|
||||
- `frontend/src/app/features/admin/general.pure.ts` — add a reusable pure default-address validator, normalization helper, and canonical inline-message mapper.
|
||||
- `frontend/src/app/features/admin/general.component.ts` — attach the new validator to the reactive-form control, expose touched/dirty invalid state to the `OnPush` template, render accessible inline feedback, and submit the normalized value.
|
||||
- `tests/backend/admin-general-service.spec.ts` — add focused Zod schema regression cases for accepted, normalized, and rejected default challenge addresses.
|
||||
- `tests/frontend/admin-general-pure.spec.ts` — add focused tests for default-address validation, messaging, and normalization without browser/UI infrastructure.
|
||||
- `docs/guides/admin-general-settings.md` — update the documented field contract, inline error selector, accessibility behavior, normalization, and test coverage.
|
||||
- `docs/api/admin.md` — document server-side trimming and IP/hostname validation for `defaultChallengeIp`.
|
||||
- **To Create:** None.
|
||||
|
||||
## 3. Proposed Changes
|
||||
1. **Database / Schema Migration:**
|
||||
- Do not alter the SQLite schema or settings keys. Keep valid persisted values unchanged, including the demonstrated IPv4 value `10.66.77.88`.
|
||||
- Strengthen `GeneralSettingsSchema.defaultChallengeIp` so Zod trims surrounding whitespace first, rejects an empty result, and accepts only a complete IPv4 address or a valid hostname. Implement the format predicate with built-in/runtime-safe logic (for example Node's `net.isIP(value) === 4` plus a hostname-label check) rather than adding a dependency. Reject incomplete dotted addresses such as `10.0.0.` and `10.0.0`, whitespace-only strings, IPv4 octets outside `0..255`, and malformed hostnames. Preserve the existing 255-character ceiling and return a clear field-specific message on `defaultChallengeIp`.
|
||||
- Because Zod transforms before `AdminGeneralService.updateSettings`, a valid padded address such as ` 10.20.30.40 ` is persisted canonically as `10.20.30.40`, while invalid values never reach `SettingsService`. This makes the backend authoritative even if the frontend is bypassed.
|
||||
|
||||
2. **Backend Logic & APIs:**
|
||||
- Keep `PUT /api/v1/admin/general/settings`, its controller, service, response shape, settings key, and SSE behavior unchanged. The existing `ZodValidationPipe` will emit the standard `400 VALIDATION_FAILED` envelope with an issue path of `defaultChallengeIp` for bad input.
|
||||
- Keep `GET /api/v1/admin/general/settings` and public bootstrap reads unchanged; successful updates continue to round-trip the normalized value through `AdminGeneralService.getSettings` and bootstrap refresh.
|
||||
- Avoid service-level duplicate validation: the parsed `GeneralSettingsPayload` is the trusted normalized contract at the service boundary.
|
||||
|
||||
3. **Frontend UI Integration:**
|
||||
- In `general.pure.ts`, add a strict pure validator compatible with Angular's validator signature. It should trim only for checking, return a dedicated error key for empty/whitespace input and another for malformed IP/hostname input, and return `null` only for a complete IPv4 address or valid hostname. Add a normalization helper that trims the submitted value and a message helper that maps raw values/control errors to stable text, including a required message for empty/whitespace-only input and a format message for incomplete or malformed addresses.
|
||||
- Replace the `defaultChallengeIp` control's `Validators.required`-only configuration with the extracted validation logic (retaining `Validators.maxLength(255)` if message handling distinguishes length). Do not use permissive browser URL parsing or `Date.parse`-style heuristics.
|
||||
- Mirror the component's established Page Title/Event field signal pattern: track the default-IP raw value, validity, and touched-or-dirty state from `valueChanges` and `statusChanges` using `takeUntilDestroyed(this.destroyRef)`, then derive `showDefaultChallengeIpError` and the message with `computed` signals. Reset the control's touched/pristine state and tracking signal in `applySettings` so valid loaded or freshly saved data does not display stale errors.
|
||||
- Enhance `[data-testid=general-defaultIp]` with conditional `aria-invalid="true"` and `aria-describedby="general-defaultIp-error"`. Render a `.field-error` element with both `id` and `data-testid="general-defaultIp-error"` whenever the field has been touched or dirtied and is invalid. This gives the empty case the required visible explanation and keeps Save disabled through `form.invalid` for incomplete/malformed values.
|
||||
- Normalize `defaultChallengeIp` with the shared helper in `onSubmit` before calling `AdminService.updateGeneralSettings`. Valid surrounding whitespace is therefore removed rather than persisted, aligning client behavior with the authoritative backend schema. Do not mutate the control during typing, so the user can see and correct the original entry.
|
||||
|
||||
## 4. Test Strategy
|
||||
- **Target Unit Test File:**
|
||||
- `tests/backend/admin-general-service.spec.ts`: extend the existing `GeneralSettingsSchema - validation rules` suite with minimal cases proving a valid IPv4 survives exactly, a valid hostname is accepted, surrounding whitespace is trimmed in parsed output, and the key negatives (`''`/whitespace-only plus representative incomplete dotted forms `10.0.0.` and `10.0.0`) fail with an issue on `defaultChallengeIp` and a clear format/required message.
|
||||
- `tests/frontend/admin-general-pure.spec.ts`: test the pure validator/message/normalizer directly: valid IPv4 and hostname return no error/message; empty and whitespace-only return the required error/message; incomplete dotted addresses return the format error/message; padded valid input normalizes to its trimmed value. Keep tests logic-only and independent of Angular TestBed or visual confirmation.
|
||||
- **Mocking Strategy:** No external services, HTTP, database, browser automation, or persistent data are needed. Parse plain objects directly with the Zod schema and invoke pure frontend helpers with small control-shaped objects, following the existing fast Jest suites. Avoid adding a component harness or full Nest application because the behavior is fully covered at the validation boundaries and the existing form wiring uses those helpers directly. Run all suites from the root with `npm test`, then run `npm run build` to verify Angular template bindings and backend TypeScript compilation.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Implementation Plan: Admin Area General Settings and Categories 1.10 — Public Landing Modal Reactivity to `registrationsEnabled`
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:** TypeScript everywhere. Monorepo with `backend/` (NestJS + TypeORM + SQLite) and `frontend/` (Angular standalone components, signals, OnPush change detection). Login is a single `LandingComponent` with a `mode = 'login' | 'register' | 'closed'` modal. Tests live in `tests/{backend,frontend}/` and run via `npm test` (jest projects). Pure helpers live next to the component that uses them (e.g. `frontend/src/app/features/landing/login-modal.service.ts`, `frontend/src/app/features/admin/general.pure.ts`).
|
||||
- **Data Layer:** SQLite via TypeORM. The `registrationsEnabled` flag is stored in the `setting` table under `SETTINGS_KEYS.REGISTRATIONS_ENABLED` (`'true'` / `'false'`). It is read by `SystemService.bootstrap()` to produce the public `BootstrapPayload` and by `AdminGeneralService.getSettings()` for the admin view. The `register` endpoint (`POST /api/v1/auth/register`) reads the same key to gate `REGISTRATIONS_DISABLED`.
|
||||
- **Test Framework & Structure:** Jest (`tests/jest.config.js`) with two projects (`backend` / `frontend`). Backend tests use `supertest` + an in-memory DB (`process.env.DATABASE_PATH=':memory:'`). Frontend tests use `jsdom` and `ts-jest`. Tests are pure (no Angular TestBed); they import only the pure helper functions and the shared event-source contract. Run with `npm test` from the repo root.
|
||||
- **Required Tools & Dependencies:** No new runtime dependencies. The fix reuses existing `SseHubService`, the public `event/stream` SSE endpoint, the existing `BootstrapService.refresh()` method, and the `landing-markdown` / `landing-modal` helpers. `setup.sh` already installs dev deps (`ts-jest`, `supertest`, `jsdom`, `typescript`) and rebuilds `better-sqlite3` — no edits needed.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
- **To Modify:**
|
||||
- `backend/src/modules/admin/general.service.ts` — include `registrationsEnabled` in the emitted `general` SSE hub payload so listeners can decide whether to refresh bootstrap.
|
||||
- `backend/src/modules/system/system.controller.ts` (`eventStream`) — flatten the `general` hub event into a `{ topic: 'general', themeKey, registrationsEnabled }` shape so the public SSE actually carries the flag instead of dropping it.
|
||||
- `frontend/src/app/core/services/bootstrap-event.service.ts` (NEW) — listed under "To Create" below; the public-facing SSE listener that converts `{ topic: 'general' }` frames into `BootstrapService.refresh()` calls. If the implementer prefers to keep it inline, the wiring lives in `frontend/src/app/app.component.ts` and `frontend/src/app/features/landing/landing.component.ts`.
|
||||
- `frontend/src/app/app.component.ts` — start the public-event listener once at app boot (after `bootstrap.load()`) so every route benefits from it.
|
||||
- `frontend/src/app/features/landing/landing.component.ts` — on `ngOnInit`, additionally call `bootstrap.refresh()` so the modal opens with the latest server state (covers the user who lands on `/login` after the in-memory cache has been mutated by another tab/process).
|
||||
- `docs/api/system.md` and `docs/guides/landing-page.md` — update the public-event flatten description and the "Reacting to admin changes" step so the tester flow matches the new flow.
|
||||
- **To Create:**
|
||||
- `frontend/src/app/core/services/bootstrap-event.service.ts` — pure, provided-in-root service that subscribes to `/api/v1/event/stream` (via the existing `EventSource`-style helper or `fetch` + `ReadableStream`), filters frames with `topic === 'general'`, and calls `bootstrap.refresh()` for each one. Exposes `start()` / `stop()` so the app can hook it to Angular's lifecycle.
|
||||
- `tests/frontend/bootstrap-event.spec.ts` — pure unit tests covering: a `general` frame triggers `bootstrap.refresh`; a non-`general` frame does not; malformed JSON is ignored; the listener is idempotent under repeated server pushes.
|
||||
- `tests/backend/admin-general-event.spec.ts` — backend unit test that asserts `AdminGeneralService.updateSettings()` emits `{ topic: 'general', themeKey, registrationsEnabled }` (so the public SSE listener can rely on the shape).
|
||||
- `tests/backend/events-status-sse-bootstrap.spec.ts` — backend integration test that asserts `/api/v1/event/stream` forwards the `general` hub payload with the `topic` preserved (independent of the legacy event-status fields).
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### 3.1 Backend — propagate the `general` SSE payload with the `registrationsEnabled` flag
|
||||
|
||||
1. **`backend/src/modules/admin/general.service.ts`** — change the `general` topic emission so it carries both the new `themeKey` and the new `registrationsEnabled` boolean, computed from the same payload the handler just persisted:
|
||||
```ts
|
||||
this.hub.emitEvent({
|
||||
topic: 'general',
|
||||
themeKey: payload.themeKey,
|
||||
registrationsEnabled: payload.registrationsEnabled,
|
||||
});
|
||||
```
|
||||
This keeps the existing shape (`topic: 'general'`) additive — non-breaking for any current consumer.
|
||||
|
||||
2. **`backend/src/modules/system/system.controller.ts`** — rewrite the `hub$` mapper inside `eventStream()` so it forwards the `general` topic's payload verbatim instead of projecting it onto the legacy event-status fields. The legacy `@Public() @Sse('event/stream')` consumer (the landing page status badge) must keep receiving its existing `status/countdownMs/startUtc/endUtc/serverNowUtc` shape, so the change is:
|
||||
- Add a `try { … } catch {}` wrapper that detects payloads matching `{ topic: 'general' }` and forwards them as-is (with a `startWith` so the public stream still emits legacy frames on its 1-second tick).
|
||||
- Keep the existing flattening for non-`general` hub events so the legacy contract is preserved for the scoreboard / event-status consumers.
|
||||
- Include the new `general` payload in the legacy public stream's `MessageEvent` so the public tab picks it up.
|
||||
|
||||
3. **No new endpoint** — we reuse the existing public `GET /api/v1/event/stream` SSE channel and prove via the new test that it now carries the `general` topic.
|
||||
|
||||
### 3.2 Frontend — listen to the public SSE and refresh bootstrap on `general`
|
||||
|
||||
1. **Create `frontend/src/app/core/services/bootstrap-event.service.ts`** — a thin, root-provided service that:
|
||||
- Opens `GET /api/v1/event/stream` once via `fetch` + `ReadableStream` (mirroring `frontend/src/app/core/services/authenticated-event-source.service.ts` but for a public endpoint, so no `Authorization` header).
|
||||
- Parses `data: {…}` frames, JSON-parses each, and dispatches:
|
||||
- If `payload.topic === 'general'`, call `BootstrapService.refresh()` and re-apply the theme (the `refresh()` already does both — see `BootstrapService.fetchAndApply` lines 73-83).
|
||||
- Otherwise, ignore (the frame is the legacy event-status payload, which the existing shell already handles via the authenticated `/events/status` stream).
|
||||
- Exposes `start()` and `stop()` methods; `start()` is idempotent (no-op if already running) and `stop()` aborts the in-flight fetch and clears listeners.
|
||||
- Catches malformed JSON frames and surface errors silently (we never want a broken SSE to break the public landing page).
|
||||
|
||||
2. **Modify `frontend/src/app/app.component.ts`** — after `await this.bootstrap.load()` and `await this.auth.restoreSession()`, call `bootstrapEvent.start()` so the listener is active for every route, including `/login`. Stop the listener on `DestroyRef.onDestroy` (in practice it lives for the life of the SPA, but the explicit teardown avoids leaks in tests).
|
||||
|
||||
3. **Modify `frontend/src/app/features/landing/landing.component.ts`** — in `ngOnInit`, after `void this.landing.refresh()`, also `void this.bootstrap.refresh()`. This is the direct fix for the "reopening the UI modal still exposed no Register control" symptom: even if the cross-tab SSE was either silenced or dropped, the public landing page that the user opens right now fetches the latest bootstrap payload before rendering the modal. The `registrationsEnabled` computed will then re-evaluate and the `@if (registrationsEnabled())` block will render the "Register here" link.
|
||||
|
||||
4. **`AuthService.register()` and `LandingComponent.submitRegister()`** — no changes are needed since the existing API endpoint already returns `201` when registrations are enabled, and the existing `buildLoginFailureMessage` already maps `REGISTRATIONS_DISABLED` to the user-facing string. The whole fix is upstream of those.
|
||||
|
||||
### 3.3 Docs — update the contract
|
||||
|
||||
1. **`docs/api/system.md`** — replace the "`event/stream` public flatten" paragraph with the new contract: the public stream now forwards `general` topic payloads (with `topic`, `themeKey`, `registrationsEnabled`) interleaved with the legacy event-status frames, and the legacy frames are still emitted on the 1-second tick for the original consumer.
|
||||
2. **`docs/guides/landing-page.md`** — add a "Refresh on admin change" subsection documenting that the public landing page calls `bootstrap.refresh()` on `ngOnInit` AND on any `general` SSE frame, so the modal's Register button is consistent with the API.
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- **Target Backend Test Files:**
|
||||
- `tests/backend/admin-general-event.spec.ts` — pure service test using a fake `SseHubService` (a `Subject<any>` is captured) that asserts `AdminGeneralService.updateSettings()` emits `{ topic: 'general', themeKey: '...', registrationsEnabled: true }` after the admin persists the new value. Mirrors the existing `tests/backend/admin-general-service.spec.ts` "persists all keys and emits a settings event" test (lines 223-247).
|
||||
- `tests/backend/events-status-sse-bootstrap.spec.ts` — full Nest app integration test (mirrors `tests/backend/events-status-sse.spec.ts`) that primes a first admin, upserts a `general` event via the public `SseHubService`, subscribes to `/api/v1/event/stream`, and asserts the SSE delivers a `data: {"topic":"general", ...}` frame with `registrationsEnabled:true` and `themeKey:classic`. Also asserts that the legacy event-status frame is still emitted on the 1-second tick so the contract is not broken.
|
||||
|
||||
- **Target Frontend Test Files:**
|
||||
- `tests/frontend/bootstrap-event.spec.ts` — pure unit tests that:
|
||||
1. Construct an in-memory EventSource substitute (a `fetch` mock returning a `ReadableStream` of SSE-shaped strings, mirroring the `authenticated-event-source.spec.ts` approach).
|
||||
2. Drive a `general` frame (`data: {"topic":"general","themeKey":"classic","registrationsEnabled":true}\n\n`) and assert the injected `BootstrapService.refresh()` has been called.
|
||||
3. Drive a non-`general` frame (`data: {"status":"running",...}\n\n`) and assert `refresh()` is NOT called.
|
||||
4. Drive a malformed JSON frame and assert the listener does not throw and does not call `refresh()`.
|
||||
5. Drive two consecutive `general` frames and assert `refresh()` is called twice (idempotent under repeated server pushes).
|
||||
- `tests/landing-modal-registration.spec.ts` (extend `tests/frontend/landing-modal.spec.ts` if preferred) — adds a tiny pure helper `landingModalShowsRegister(registrationsEnabled: boolean)` that mirrors the `@if (registrationsEnabled())` predicate and asserts it returns `true` exactly when `registrationsEnabled` is `true`. This locks the contract that the modal's switch button is only visible when the bootstrap payload says it should be — i.e. the "modal exposes a Register control iff the API will accept a registration" invariant.
|
||||
|
||||
- **Mocking Strategy:**
|
||||
- Backend tests: use the existing `tests/backend/db-helper.ts` to seed a single admin via `POST /api/v1/auth/register-first-admin`. The `SseHubService` is consumed directly (subscribe to `hub.event$()`) to assert the payload shape without going through the full app — fast and isolated. The full Nest integration test reuses the bootstrapping pattern from `tests/backend/events-status-sse.spec.ts`.
|
||||
- Frontend tests: stub `fetch` with a `ReadableStream` that emits SSE-encoded frames (mirrors `tests/frontend/authenticated-event-source.spec.ts`'s `makeCaptureableFetch`). Inject a `BootstrapService` whose `refresh()` is a `jest.fn()` that resolves to a minimal `BootstrapPayload`. The Angular `DestroyRef` is faked with a no-op `onDestroy` callback so the test owns the listener lifecycle.
|
||||
- No TestBed, no Karma, no visual assertions. All tests run via `npm test` from the repo root and finish in well under a second each.
|
||||
|
||||
- **Reusable test fixtures from `/data`:** the `/data/hipctf/db.sqlite` file is used by the running server, not by the tests. The test fixtures are created on-the-fly by `tests/backend/db-helper.ts` via `initDb(app)` so no persistent state is required. If the implementer prefers to seed from `/data`, the helper can be extended to copy the SQLite file only when `process.env.USE_DATA_DB === 'true'` (the plan does not require this).
|
||||
@@ -69,7 +69,11 @@ export class AdminGeneralService {
|
||||
this.settings.set(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, payload.defaultChallengeIp),
|
||||
this.settings.set(SETTINGS_KEYS.REGISTRATIONS_ENABLED, payload.registrationsEnabled ? 'true' : 'false'),
|
||||
]);
|
||||
this.hub.emitEvent({ topic: 'general', themeKey: payload.themeKey });
|
||||
this.hub.emitEvent({
|
||||
topic: 'general',
|
||||
themeKey: payload.themeKey,
|
||||
registrationsEnabled: payload.registrationsEnabled,
|
||||
});
|
||||
return this.getSettings();
|
||||
}
|
||||
|
||||
|
||||
@@ -62,13 +62,22 @@ export class SystemController {
|
||||
),
|
||||
);
|
||||
const hub$ = this.hub.event$().pipe(
|
||||
map((payload: any) => ({
|
||||
status: payload?.status,
|
||||
countdownMs: payload?.countdownMs,
|
||||
serverNowUtc: payload?.serverNowUtc,
|
||||
startUtc: payload?.startUtc,
|
||||
endUtc: payload?.endUtc,
|
||||
})),
|
||||
map((payload: any) => {
|
||||
if (payload && payload.topic === 'general') {
|
||||
return {
|
||||
topic: 'general',
|
||||
themeKey: payload.themeKey,
|
||||
registrationsEnabled: payload.registrationsEnabled,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: payload?.status,
|
||||
countdownMs: payload?.countdownMs,
|
||||
serverNowUtc: payload?.serverNowUtc,
|
||||
startUtc: payload?.startUtc,
|
||||
endUtc: payload?.endUtc,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return merge(tick$, hub$).pipe(map((src) => ({ data: src }) as MessageEvent));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { BootstrapService } from './core/services/bootstrap.service';
|
||||
import { BootstrapEventService } from './core/services/bootstrap-event.service';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
@@ -9,11 +10,17 @@ import { AuthService } from './core/services/auth.service';
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private bootstrap = inject(BootstrapService);
|
||||
private auth = inject(AuthService);
|
||||
private readonly bootstrapEvent = inject(BootstrapEventService);
|
||||
async ngOnInit() {
|
||||
await this.bootstrap.load();
|
||||
await this.auth.restoreSession();
|
||||
this.bootstrapEvent.start();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.bootstrapEvent.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { EventSourceLike } from './event-status.pure';
|
||||
|
||||
export function isBootstrapGeneralFrame(frame: unknown): frame is { topic: 'general'; themeKey?: string; registrationsEnabled?: boolean } {
|
||||
if (!frame || typeof frame !== 'object') return false;
|
||||
const f = frame as Record<string, unknown>;
|
||||
return f['topic'] === 'general';
|
||||
}
|
||||
|
||||
export type BootstrapGeneralPayload = { topic: 'general'; themeKey?: string; registrationsEnabled?: boolean };
|
||||
|
||||
export function parseSseDataLines(raw: string): string | null {
|
||||
let event = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith(':')) continue;
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return dataLines.join('\n');
|
||||
}
|
||||
|
||||
export function makeBootstrapEventSource(url: string, fetchImpl: typeof globalThis.fetch = fetch): EventSourceLike {
|
||||
const listeners = new Map<string, Array<(ev: MessageEvent | Event) => void>>();
|
||||
let controller: AbortController | null = new AbortController();
|
||||
let closed = false;
|
||||
let buffered: string[] = [];
|
||||
|
||||
const dispatch = (type: string, ev: MessageEvent | Event) => {
|
||||
const arr = listeners.get(type);
|
||||
if (!arr) return;
|
||||
for (const fn of arr) {
|
||||
try {
|
||||
fn(ev);
|
||||
} catch {
|
||||
// ignore listener errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const flushBuffered = () => {
|
||||
if (closed) return;
|
||||
const items = buffered;
|
||||
buffered = [];
|
||||
for (const data of items) {
|
||||
dispatch('message', new MessageEvent('message', { data }));
|
||||
}
|
||||
};
|
||||
|
||||
const fireError = () => {
|
||||
if (closed) return;
|
||||
dispatch('error', new Event('error'));
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
credentials: 'include',
|
||||
signal: controller!.signal,
|
||||
});
|
||||
if (!res.ok || !(res as unknown as { body?: unknown }).body) {
|
||||
fireError();
|
||||
return;
|
||||
}
|
||||
const body = (res as unknown as { body: ReadableStream<Uint8Array> }).body;
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
||||
const raw = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const payload = parseSseDataLines(raw);
|
||||
if (payload === null) continue;
|
||||
if (listeners.has('message')) {
|
||||
dispatch('message', new MessageEvent('message', { data: payload }));
|
||||
} else {
|
||||
buffered.push(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!closed) fireError();
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
const arr = listeners.get(type) ?? [];
|
||||
arr.push(listener);
|
||||
listeners.set(type, arr);
|
||||
if (type === 'message' && buffered.length > 0) {
|
||||
queueMicrotask(flushBuffered);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (controller) {
|
||||
try {
|
||||
controller.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
controller = null;
|
||||
}
|
||||
listeners.clear();
|
||||
buffered = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BootstrapService } from './bootstrap.service';
|
||||
import { EventSourceLike } from './event-status.pure';
|
||||
import {
|
||||
isBootstrapGeneralFrame,
|
||||
makeBootstrapEventSource,
|
||||
} from './bootstrap-event.pure';
|
||||
|
||||
export {
|
||||
isBootstrapGeneralFrame,
|
||||
parseSseDataLines,
|
||||
makeBootstrapEventSource as defaultCreateSource,
|
||||
} from './bootstrap-event.pure';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BootstrapEventService {
|
||||
private readonly bootstrap = inject(BootstrapService);
|
||||
private current: EventSourceLike | null = null;
|
||||
private messageHandler: ((ev: MessageEvent | Event) => void) | null = null;
|
||||
private errorHandler: ((ev: MessageEvent | Event) => void) | null = null;
|
||||
|
||||
start(createSource: (url: string) => EventSourceLike = makeBootstrapEventSource): void {
|
||||
if (this.current) return;
|
||||
const source = createSource('/api/v1/event/stream');
|
||||
this.messageHandler = (ev) => this.handleMessage(ev);
|
||||
this.errorHandler = () => {
|
||||
this.stop();
|
||||
};
|
||||
source.addEventListener('message', this.messageHandler);
|
||||
source.addEventListener('error', this.errorHandler);
|
||||
this.current = source;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const src = this.current;
|
||||
if (!src) return;
|
||||
this.current = null;
|
||||
this.messageHandler = null;
|
||||
this.errorHandler = null;
|
||||
try {
|
||||
src.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.current !== null;
|
||||
}
|
||||
|
||||
handleFrame(frame: unknown): Promise<void> {
|
||||
if (!isBootstrapGeneralFrame(frame)) return Promise.resolve();
|
||||
return this.bootstrap.refresh().then(() => undefined);
|
||||
}
|
||||
|
||||
private handleMessage(ev: MessageEvent | Event): void {
|
||||
const data = (ev as MessageEvent).data;
|
||||
if (typeof data !== 'string') return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void this.handleFrame(parsed);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export class LandingComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.landing.refresh();
|
||||
void this.bootstrap.refresh();
|
||||
}
|
||||
|
||||
openLogin(): void {
|
||||
|
||||
@@ -43,4 +43,14 @@ export function buildLoginFailureMessage(input: FailureMessageInput): FailureMes
|
||||
default:
|
||||
return { text: input.message || 'Something went wrong. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure predicate that mirrors `landing.component.html`'s `@if (registrationsEnabled())`
|
||||
* guard around the "Register here" / "Login here" switch button. Keeping it here
|
||||
* lets the test suite lock in the contract "the modal exposes a Register control
|
||||
* iff the bootstrap payload says the API will accept a registration."
|
||||
*/
|
||||
export function landingModalShowsRegister(registrationsEnabled: unknown): boolean {
|
||||
return registrationsEnabled === true;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { AdminGeneralService } from '../../backend/src/modules/admin/general.service';
|
||||
|
||||
function makeFakeThemeLoader() {
|
||||
return [
|
||||
{ id: 'classic', name: 'Classic', tokens: {} as any },
|
||||
{ id: 'midnight', name: 'Midnight', tokens: {} as any },
|
||||
];
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
const stored: Record<string, string> = {};
|
||||
const fakeSettings = {
|
||||
get: jest.fn().mockImplementation(async (k: string, d: string) => (k in stored ? stored[k] : d)),
|
||||
set: jest.fn().mockImplementation(async (k: string, v: string) => { stored[k] = v; }),
|
||||
} as any;
|
||||
const fakeHub = { emitEvent: jest.fn() } as any;
|
||||
const fakeThemes: any = { listThemes: () => makeFakeThemeLoader() };
|
||||
const fakeConfig: any = { get: jest.fn().mockReturnValue('./themes') };
|
||||
const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig);
|
||||
return { svc, fakeHub, fakeSettings };
|
||||
}
|
||||
|
||||
describe('AdminGeneralService.updateSettings - general SSE payload (Job 892)', () => {
|
||||
it('emits a general hub event carrying both themeKey and registrationsEnabled when registrations are enabled', async () => {
|
||||
const { svc, fakeHub } = makeService();
|
||||
await svc.updateSettings({
|
||||
pageTitle: 'Hello',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'midnight',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: true,
|
||||
});
|
||||
expect(fakeHub.emitEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
topic: 'general',
|
||||
themeKey: 'midnight',
|
||||
registrationsEnabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a general hub event with registrationsEnabled=false when the admin disables registrations', async () => {
|
||||
const { svc, fakeHub } = makeService();
|
||||
await svc.updateSettings({
|
||||
pageTitle: 'Hello',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(fakeHub.emitEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
topic: 'general',
|
||||
themeKey: 'classic',
|
||||
registrationsEnabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.NODE_ENV = '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 { SseHubService } from '../../backend/src/common/services/sse-hub.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('GET /api/v1/event/stream forwards general topic (Job 892)', () => {
|
||||
let app: INestApplication;
|
||||
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);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
hub = app.get(SseHubService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('emits a { topic: "general", themeKey, registrationsEnabled } frame through the public stream', (done) => {
|
||||
const server = app.getHttpServer();
|
||||
const req = request(server).get('/api/v1/event/stream');
|
||||
let received = false;
|
||||
req
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const finalize = () => cb(null, Buffer.concat(chunks));
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
if (received) return;
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const match = /data: (\{[^{]*?"topic":\s*"general"[^{]*\})\n/.exec(text);
|
||||
if (!match) return;
|
||||
try {
|
||||
const payload = JSON.parse(match[1]);
|
||||
expect(payload.topic).toBe('general');
|
||||
expect(payload).toHaveProperty('themeKey');
|
||||
expect(payload).toHaveProperty('registrationsEnabled');
|
||||
received = true;
|
||||
(res as any).destroy();
|
||||
done();
|
||||
} catch (e) {
|
||||
(res as any).destroy();
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
res.on('end', finalize);
|
||||
res.on('error', (err) => cb(err, null));
|
||||
})
|
||||
.end(() => {});
|
||||
|
||||
// Push the hub event shortly after the consumer attaches.
|
||||
setTimeout(() => {
|
||||
hub.emitEvent({ topic: 'general', themeKey: 'midnight', registrationsEnabled: true });
|
||||
}, 150);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!received) {
|
||||
done(new Error('Did not receive a general frame within 5s'));
|
||||
}
|
||||
}, 5000);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const webStreams = require('node:stream/web');
|
||||
const util = require('node:util');
|
||||
|
||||
if (typeof (globalThis as any).ReadableStream === 'undefined') {
|
||||
(globalThis as any).ReadableStream = webStreams.ReadableStream;
|
||||
}
|
||||
if (typeof (globalThis as any).TextEncoder === 'undefined') {
|
||||
(globalThis as any).TextEncoder = util.TextEncoder;
|
||||
}
|
||||
if (typeof (globalThis as any).TextDecoder === 'undefined') {
|
||||
(globalThis as any).TextDecoder = util.TextDecoder;
|
||||
}
|
||||
|
||||
import {
|
||||
isBootstrapGeneralFrame,
|
||||
makeBootstrapEventSource as defaultCreateSource,
|
||||
} from '../../frontend/src/app/core/services/bootstrap-event.pure';
|
||||
|
||||
function makeCaptureableFetch(frames: string[]): { fetchMock: jest.Mock; readCaptured(): { url: string } | null } {
|
||||
const encoder = new TextEncoder();
|
||||
const state: { captured: { url: string } | null } = { captured: null };
|
||||
const fetchMock = jest.fn(async (url: string) => {
|
||||
state.captured = { url };
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
for (const f of frames) controller.enqueue(encoder.encode(f));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return { ok: true, status: 200, body } as unknown as Response;
|
||||
});
|
||||
return { fetchMock, readCaptured: () => state.captured };
|
||||
}
|
||||
|
||||
describe('isBootstrapGeneralFrame', () => {
|
||||
it('accepts a frame with topic === "general"', () => {
|
||||
expect(isBootstrapGeneralFrame({ topic: 'general', registrationsEnabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects frames that do not carry topic === "general"', () => {
|
||||
expect(isBootstrapGeneralFrame({ topic: 'scoreboard' })).toBe(false);
|
||||
expect(isBootstrapGeneralFrame({ registrationsEnabled: true })).toBe(false);
|
||||
expect(isBootstrapGeneralFrame({ status: 'running' })).toBe(false);
|
||||
expect(isBootstrapGeneralFrame(null)).toBe(false);
|
||||
expect(isBootstrapGeneralFrame(undefined)).toBe(false);
|
||||
expect(isBootstrapGeneralFrame('general')).toBe(false);
|
||||
expect(isBootstrapGeneralFrame(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultCreateSource (public SSE opener)', () => {
|
||||
let originalFetch: typeof globalThis.fetch | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = (globalThis as any).fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('opens /api/v1/event/stream with credentials and Accept text/event-stream', async () => {
|
||||
const f = makeCaptureableFetch([]);
|
||||
(globalThis as any).fetch = f.fetchMock as any;
|
||||
const src = defaultCreateSource('/api/v1/event/stream');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const captured = f.readCaptured();
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.url).toBe('/api/v1/event/stream');
|
||||
src.close();
|
||||
});
|
||||
|
||||
it('dispatches SSE message frames to message listeners', async () => {
|
||||
const frame =
|
||||
'data: {"status":"Running","countdownMs":1,"serverNowUtc":"2026-07-22T00:00:00.000Z","startUtc":null,"endUtc":null}\n\n';
|
||||
const f = makeCaptureableFetch([frame]);
|
||||
(globalThis as any).fetch = f.fetchMock as any;
|
||||
const src = defaultCreateSource('/api/v1/event/stream');
|
||||
const received: any[] = [];
|
||||
src.addEventListener('message', (ev) => {
|
||||
const me = ev as MessageEvent;
|
||||
received.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(received.length).toBeGreaterThanOrEqual(1);
|
||||
expect(received[0].status).toBe('Running');
|
||||
src.close();
|
||||
});
|
||||
|
||||
it('buffers early frames and flushes them when a message listener attaches', async () => {
|
||||
const frame =
|
||||
'data: {"topic":"general","themeKey":"classic","registrationsEnabled":true}\n\n';
|
||||
const f = makeCaptureableFetch([frame]);
|
||||
(globalThis as any).fetch = f.fetchMock as any;
|
||||
const src = defaultCreateSource('/api/v1/event/stream');
|
||||
// Wait for the underlying reader to drain before adding the listener
|
||||
// so we exercise the buffering path.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const received: any[] = [];
|
||||
src.addEventListener('message', (ev) => {
|
||||
const me = ev as MessageEvent;
|
||||
received.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(received.length).toBeGreaterThanOrEqual(1);
|
||||
expect(received.some((r) => r && r.topic === 'general')).toBe(true);
|
||||
src.close();
|
||||
});
|
||||
|
||||
it('forwards error events when the underlying fetch reports failure', async () => {
|
||||
const fetchMock = jest.fn(async () => ({ ok: false, status: 500, body: null } as unknown as Response));
|
||||
(globalThis as any).fetch = fetchMock as any;
|
||||
const src = defaultCreateSource('/api/v1/event/stream');
|
||||
const errors: string[] = [];
|
||||
src.addEventListener('error', (ev) => errors.push((ev as Event).type));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(errors).toContain('error');
|
||||
src.close();
|
||||
});
|
||||
|
||||
it('close() is idempotent and safe to call twice', async () => {
|
||||
const f = makeCaptureableFetch([]);
|
||||
(globalThis as any).fetch = f.fetchMock as any;
|
||||
const src = defaultCreateSource('/api/v1/event/stream');
|
||||
src.close();
|
||||
expect(() => src.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { buildLoginFailureMessage } from '../../frontend/src/app/features/landing/login-modal.service';
|
||||
import {
|
||||
buildLoginFailureMessage,
|
||||
landingModalShowsRegister,
|
||||
} from '../../frontend/src/app/features/landing/login-modal.service';
|
||||
|
||||
describe('buildLoginFailureMessage', () => {
|
||||
it.each([
|
||||
@@ -50,4 +53,16 @@ describe('buildLoginFailureMessage', () => {
|
||||
expect(buildLoginFailureMessage({ code: 'EXOTIC', message: 'Quux' }).text).toBe('Quux');
|
||||
expect(buildLoginFailureMessage({ code: 'EXOTIC', message: '' }).text).toBe('Something went wrong. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('landingModalShowsRegister', () => {
|
||||
it('returns true only when registrationsEnabled is exactly true', () => {
|
||||
expect(landingModalShowsRegister(true)).toBe(true);
|
||||
expect(landingModalShowsRegister(false)).toBe(false);
|
||||
expect(landingModalShowsRegister(null)).toBe(false);
|
||||
expect(landingModalShowsRegister(undefined)).toBe(false);
|
||||
expect(landingModalShowsRegister('true')).toBe(false);
|
||||
expect(landingModalShowsRegister(1)).toBe(false);
|
||||
expect(landingModalShowsRegister({})).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user