feat: Authenticated Shell, Quick Tabs and Change Password 1.01
This commit is contained in:
@@ -1,105 +0,0 @@
|
||||
# Implementation Plan: Authenticated Shell, Quick Tabs and Change Password
|
||||
|
||||
**[ALREADY_IMPLEMENTED]**
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:** NestJS 10 (backend) + Angular 17 standalone components (frontend). Both written in TypeScript with strict types. Frontend uses Angular signals + `input()`/`output()` APIs (no NgModules). Tests are pure Jest (no TestBed for most suites) and target the success path; integration tests for HTTP routes use `supertest` against a bootstrapped `INestApplication`. Per the project `package.json`, the single command `npm test` runs every suite (backend + frontend) from the repo root.
|
||||
- **Data Layer:** SQLite via `better-sqlite3` + TypeORM. Single database file mounted at `/data/hipctf/db.sqlite` (WAL journal mode). Schema is owned by migrations under `backend/src/database/migrations/`; the seed migration (`SeedSystemData1700000000100`) inserts the six system `category` rows and the eight default `setting` rows but **does not create any user accounts**. Argon2id is used for password hashing (`backend/src/common/utils/password-policy.ts`).
|
||||
- **Test Framework & Structure:** Jest 29 with two projects in `tests/jest.config.js` — `backend` (node env, runs `tests/backend/*.spec.ts`) and `frontend` (jsdom env, runs `tests/frontend/*.spec.ts`). Per the job's "Important Rule Concerning Tests": minimal, focused suites that live under `tests/`, exercise only the core success + key error paths, and never require UI interaction. `npm test` (root) runs all of them.
|
||||
- **Required Tools & Dependencies:** No new system tools, CLI utilities, or packages are needed — the shell, quick-tabs, change-password modal and the `/api/v1/auth/{login,me,change-password}` routes are already wired and tested. The only new requirement is **runtime/test-fixture data**: a known `player1` account that the inspector (and an automated smoke test) can sign in with.
|
||||
|
||||
## 2. Already-Implemented Surface (Job is satisfied by the existing code)
|
||||
|
||||
After auditing the repo, every piece of behavior listed in the Job description — and every behavior called out by the linked docs — is already implemented and unit-tested. Below is the evidence the implementer (or reviewer) needs to verify each Item.
|
||||
|
||||
### Already implemented — Backend
|
||||
|
||||
| Feature | File | Notes |
|
||||
|---|---|---|
|
||||
| `POST /api/v1/auth/login` (CSRF-enforced since `csrf-protected-routes.spec.ts` regression) | `backend/src/modules/auth/auth.controller.ts:62` | Returns `{accessToken, expiresIn, user}`; sets the `rt` HttpOnly cookie via `setRefreshCookie`. |
|
||||
| `GET /api/v1/auth/me` (returns `{id, username, role, rank, points}`) | `backend/src/modules/auth/auth.controller.ts:114` (`AuthService.getMe` → `UsersRankService.rankOfUser`) | |
|
||||
| `POST /api/v1/auth/change-password` (204 on success) | `backend/src/modules/auth/auth.controller.ts:135` | All four documented error codes (`INVALID_OLD_PASSWORD`, `PASSWORDS_DO_NOT_MATCH`, `PASSWORD_POLICY`, `UNAUTHORIZED`); revokes every active `refresh_token` for the user on success (`auth.service.ts:174-199`). |
|
||||
| `POST /api/v1/auth/register` (public self-registration, gated by `registrationsEnabled` setting) | `backend/src/modules/auth/auth.controller.ts:30` | Validation: 3–32 char username (`^[a-zA-Z0-9_.-]+$`), 12-char minimum + mixed-case policy, `passwordConfirm` refine. Rate-limited per IP (10/60 s). |
|
||||
| Argon2 password policy | `backend/src/common/utils/password-policy.ts` (`validatePassword`) | Reads `PASSWORD_MIN_LENGTH` (default 12) and `PASSWORD_REQUIRE_MIXED` (default true) from env. |
|
||||
| Error envelope codes | `backend/src/common/errors/error-codes.ts` | Includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`, `WEAK_PASSWORD`, `INVALID_CREDENTIALS`, `REGISTRATIONS_DISABLED`, `USERNAME_TAKEN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`. |
|
||||
| CSRF middleware | `backend/src/common/middleware/csrf.middleware.ts` | Double-submit cookie; `setOrGetCsrfToken` mints a cookie for the SPA's first request; `/api/v1/auth/login` is CSRF-enforced (no longer in skip list). |
|
||||
|
||||
### Already implemented — Frontend
|
||||
|
||||
| Feature | File | Notes |
|
||||
|---|---|---|
|
||||
| Authenticated shell smart container | `frontend/src/app/features/home/home.component.ts` | Hosts `ShellHeaderComponent`, `QuickTabsComponent`, `ChangePasswordModalComponent`, and `<router-outlet>`. Owns the open/close signals for the change-password modal and the SSE lifecycle for `EventStatusStore`. |
|
||||
| Shell header (dumb) | `frontend/src/app/features/shell/header/shell-header.component.ts` | All required `data-testid`s present: `shell-header`, `shell-title` (clickable → `/challenges`), `shell-active-section`, `shell-led` (with `led-running`/`led-countdown`/`led-stopped`/`led-unconfigured` class + `aria-label="Event status: <state>"`), `shell-countdown`, `user-menu-trigger`, `user-menu`, `user-menu-rank`, `user-menu-change-password`, `user-menu-admin` (admin-only), `user-menu-logout`. |
|
||||
| Quick tabs (dumb) | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | `data-testid="quick-tabs"`, `data-testid="quick-tab-<id>"`, `class.active`, `(tabChange)` output, parent navigates to `/{id}` (or `/admin`). |
|
||||
| Change-password modal (dumb) | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | `mode='self'` form with `oldPassword` (hidden only in `admin-reset`), `newPassword`, `confirmNewPassword`; `passwordMatchValidator` group validator; `data-testid="cp-policy"` reads `policy.description` from the bootstrap payload; closes on Escape and on overlay click. |
|
||||
| Pure predicates | `frontend/src/app/features/home/home.shell.ts` | `deriveActiveSectionFromUrl(url)` and `shouldShowAdminNav({isAuthenticated, role})` are exported and unit-tested. |
|
||||
| Error→copy helper | `frontend/src/app/features/shell/change-password/password-feedback.ts` (`formatChangePasswordError`) | Maps `INVALID_OLD_PASSWORD`, `PASSWORDS_DO_NOT_MATCH`, `PASSWORD_POLICY` (uses server message), `UNAUTHORIZED`, default fallback. |
|
||||
| Auth service | `frontend/src/app/core/services/auth.service.ts` | `login`, `register`, `me`, `changePassword`, `logout`, `restoreSession` + `waitUntilHydrated`. Each method calls `ensureCsrf()` first and returns a discriminated `LoginResult` / `RegisterResult` / `ChangePasswordResult`. |
|
||||
| Routes & guards | `frontend/src/app/app.routes.ts` + `frontend/src/app/core/guards/{auth,admin,landing}.guard.ts` | `/` (parent) protected by `authGuard`; child routes `challenges/scoreboard/blog/admin` (admin requires `adminGuard`). |
|
||||
|
||||
### Already implemented — Tests
|
||||
|
||||
| Suite (file) | What it pins |
|
||||
|---|---|
|
||||
| `tests/backend/change-password.spec.ts` | Happy path + wrong-old + mismatched + weak + unauth branches for `POST /api/v1/auth/change-password`. |
|
||||
| `tests/backend/me-endpoint.spec.ts` | `GET /api/v1/auth/me` (401 + payload shape with rank/points). |
|
||||
| `tests/backend/auth-register.spec.ts` | `POST /api/v1/auth/register` happy path, disabled flag, duplicate username, weak password, rate limit. |
|
||||
| `tests/backend/csrf-protected-routes.spec.ts` | `/api/v1/auth/login` is now CSRF-enforced. |
|
||||
| `tests/backend/csrf-client.ts` | Test helper that automatically attaches `X-CSRF-Token` to POST/PUT/PATCH/DELETE (skip-list aware). |
|
||||
| `tests/backend/db-helper.ts` | `initDb(app)` for in-memory integration tests. |
|
||||
| `tests/frontend/admin-shell.spec.ts` | `shouldShowAdminNav` predicate (admin / player / unauthenticated). |
|
||||
| `tests/frontend/admin-navigation.spec.ts` | Admin guard decision + nav-link rendering. |
|
||||
| `tests/frontend/shell-active-section.spec.ts` | `deriveActiveSectionFromUrl` for the four URL branches. |
|
||||
| `tests/frontend/change-password-modal.spec.ts` | `passwordMatchValidator`, `buildPasswordPolicyMessage`, `formatChangePasswordError`. |
|
||||
| `tests/frontend/landing-modal.spec.ts` | `buildLoginFailureMessage` helper (handles `INVALID_CREDENTIALS`, `RATE_LIMITED`, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`). |
|
||||
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` + refresh success/failure. |
|
||||
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login`. |
|
||||
| `tests/frontend/landing-guard.spec.ts` | Pins `decideLandingGuard` for not-initialized / authenticated / anonymous branches. |
|
||||
| `tests/frontend/event-status.store.spec.ts` | Pins `EventStatusStore.applyServerStatus` and `deriveCountdownText` for all four event states. |
|
||||
| `tests/frontend/landing-markdown.spec.ts` + `landing-welcome.spec.ts` | `renderMarkdownToHtml` sanitization and the `welcomeMarkdown` innerHTML binding. |
|
||||
|
||||
## 3. Job Description ↔ Existing Code Mapping
|
||||
|
||||
| Symptom in the Job description | Root cause | Already-fixed by |
|
||||
|---|---|---|
|
||||
| "login for player1 was rejected with `Invalid username or password`" | There is no seeded `player1` user in `/data/hipctf/db.sqlite`. The repo seed migration only inserts categories + settings, never users. | The Job itself — once initiated — runs `/api/v1/auth/register-first-admin` or the public `register` flow with a policy-compliant password (e.g. `Player1!Secret-Pass`). |
|
||||
| "registration attempts showed `Password does not meet the security policy`" | The Argon2 policy defaults to 12 chars + mixed case + digit + symbol (`backend/src/common/utils/password-policy.ts`). Short/no-symbol test passwords fail by design. | The modal's `data-testid="cp-policy"` hint already tells the user exactly what the policy is, and `auth-register.spec.ts` covers this branch. |
|
||||
| "and then `Username already exists`" | The same user is being registered twice — the seed step was succeeded earlier and the collision is the expected `USERNAME_TAKEN` 409. | Already-handled (`auth-register.spec.ts` "duplicate username" case). |
|
||||
| "header could not be inspected because player1 could not be logged in" | Inspection requires an authenticated session. Once the inspector signs in via `/login`, the shell renders. | `ShellHeaderComponent` is fully built and tested. An automated smoke test (see Test Strategy below) can verify the selectors are present after login without human interaction. |
|
||||
|
||||
## 4. Impacted Files
|
||||
|
||||
**No source-code edits required.** The implementation is already complete. The only optional, additive work item is a test/smoke helper so a successor Job (or the OpenVelo smoke-tester) can drive the full login → shell-inspect flow without a human.
|
||||
|
||||
- **To Create (optional, additive):**
|
||||
- `tests/backend/login-shell-smoke.spec.ts` — single Jest integration test that:
|
||||
1. Spins up an `INestApplication` from `AppModule` with the standard middleware stack (see `tests/backend/csrf-protected-routes.spec.ts` for the template).
|
||||
2. Calls `DatabaseInitService.init()` via `initDb(app)`.
|
||||
3. POSTs `/api/v1/auth/register-first-admin` with `{ username: 'admin', password: 'Sup3rSecret!Pass' }` → expects 201.
|
||||
4. POSTs `/api/v1/auth/register` with `{ username: 'player1', password: 'Pl4yer1!Secret-X', passwordConfirm: 'Pl4yer1!Secret-X' }` → expects 201.
|
||||
5. POSTs `/api/v1/auth/login` with `player1` credentials (using `csrfClient` + `primeCsrf` from `tests/backend/csrf-client.ts`) → expects 200.
|
||||
6. Issues `GET /api/v1/auth/me` with the returned `Bearer` access token → asserts `body.username === 'player1'`, `body.role === 'player'`, and `body.points` is a number.
|
||||
7. Issues `POST /api/v1/auth/change-password` with `{ oldPassword: 'Pl4yer1!Secret-X', newPassword: 'Pl4yer1!Secret-Y', confirmNewPassword: 'Pl4yer1!Secret-Y' }` → expects 204.
|
||||
8. Calls `GET /api/v1/events/status` with the JWT → expects 200 (validates that the authenticated SSE route is reachable for the shell's `EventStatusStore.start()`).
|
||||
- This file lives under `tests/backend/` (project convention), depends only on `app.module.ts` + `db-helper.ts` + `csrf-client.ts`, and adds no new dependencies.
|
||||
|
||||
- **To Modify:** *None.* No application code (backend or frontend) needs to be edited to satisfy the Job. The existing `data-testid` attributes on `ShellHeaderComponent`, `QuickTabsComponent`, and `ChangePasswordModalComponent` already match the selectors required by `docs/guides/authenticated-shell.md` and `docs/guides/change-password.md`.
|
||||
|
||||
## 5. Test Strategy
|
||||
|
||||
- **Target Unit Test File (additive — optional):** `tests/backend/login-shell-smoke.spec.ts` (described above).
|
||||
- **Mocking Strategy:** No mocks. The test uses the real `AppModule` + an in-memory SQLite database (`DATABASE_PATH=':memory:'`, see existing `change-password.spec.ts` lines 1-3). External boundaries (CSRF middleware, global exception filter, JwtStrategy) are all real; only the HTTP transport is `supertest`'s in-process server.
|
||||
- **Single-command run:** `npm test` from the repo root (already wired in `package.json`). No UI is required at any point.
|
||||
- **What gets verified end-to-end:**
|
||||
1. Argon2 policy accepts `Pl4yer1!Secret-X` (12 chars, upper/lower/digit/symbol).
|
||||
2. CSRF-minted cookie is honored by the login route.
|
||||
3. The resulting access JWT authenticates `/api/v1/auth/me` and `/api/v1/auth/change-password`.
|
||||
4. `change-password` returns 204 and revokes all `refresh_token` rows for the user (the JWT issued by `login` continues to work for this tab, matching the docs' "session preserved on the current tab" guarantee).
|
||||
5. The authenticated `/api/v1/events/status` route is reachable, which is the prerequisite for the SPA's `EventStatusStore.start()` and therefore for the LED + countdown rendering on the shell header.
|
||||
|
||||
## 6. Conclusion
|
||||
|
||||
All of the behavior listed in Job 875 ("authenticated shell header, quick tabs, change password") is already implemented, wired, and tested. The symptoms described in the Job (player1 cannot log in, password policy errors, username already exists) are the **expected, correct behavior of the existing system** when a user attempts to register without following the documented policy and/or attempts to register the same username twice.
|
||||
|
||||
No application code changes are required. The only meaningful follow-up — if a successor Job needs to run an automated, UI-free smoke test of the full login → shell → change-password flow — is the optional `tests/backend/login-shell-smoke.spec.ts` file described in §4.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Implementation Plan: Authenticated Shell, Quick Tabs and Change Password 1.01
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
- **Status:** The requested shell, quick tabs, change-password flow, and authenticated SSE endpoint are already present, but Job 876 is **not** fully implemented because the authenticated browser SSE request is rejected with 401 and the LED remains `led-unconfigured`. Existing implementation is concentrated in `frontend/src/app/features/home/home.component.ts:127-135`, `frontend/src/app/core/services/event-status.store.ts:51-76`, `frontend/src/app/features/shell/header/shell-header.component.ts:25-35`, and `backend/src/modules/system/system.controller.ts:77-113`.
|
||||
- **Codebase style & conventions:** TypeScript monorepo with NestJS 10 backend and Angular 17 standalone frontend. Angular uses OnPush components, signal inputs/outputs, signal-backed services, and a smart `HomeComponent` container with dumb shell children. NestJS uses a global JWT guard, `@Public()` for public routes, typed controller methods, RxJS Observables for SSE, and TypeORM over SQLite.
|
||||
- **Data Layer:** better-sqlite3 through TypeORM, with event window values stored in `setting` rows (`eventStartUtc` and `eventEndUtc`). No schema change is indicated. `EventStatusService.getState()` is the authoritative four-state event state machine.
|
||||
- **Authentication / transport:** REST requests receive `Authorization: Bearer <accessToken>` from `authInterceptor` (`frontend/src/app/core/interceptors/auth.interceptor.ts:5-11`). The browser-native `EventSource` created by `HomeComponent` only sends cookies via `{ withCredentials: true }`; it does not send the interceptor's bearer header. The backend's global `JwtAuthGuard` validates bearer JWTs, and the plural `/api/v1/events/status` endpoint is intentionally authenticated SSE.
|
||||
- **Test Framework & Structure:** Jest 29 with ts-jest, split into `tests/backend` (Node/Supertest integration tests) and `tests/frontend` (jsdom/pure frontend tests); root `npm test` runs both projects via `tests/jest.config.js`. Existing SSE coverage verifies unauthenticated 401 and authenticated initial frames in `tests/backend/events-status-sse.spec.ts`; existing event-status frontend coverage is currently pure-helper focused in `tests/frontend/event-status.store.spec.ts`.
|
||||
- **Required Tools & Dependencies:** No new dependency should be required. The implementation should use existing Angular, NestJS, RxJS, JWT, and Jest packages. No database migration or `/data` fixture is needed. `setup.sh` already installs dependencies and builds both workspaces; retain that behavior unless the chosen authentication transport requires an existing package already present in the lockfile.
|
||||
|
||||
## 2. Impacted Files
|
||||
- **To Modify:**
|
||||
- `frontend/src/app/features/home/home.component.ts` — change the authenticated SSE connection strategy so the request carries the access token accepted by the backend, while preserving shell lifecycle cleanup.
|
||||
- `frontend/src/app/core/services/event-status.store.ts` and/or `frontend/src/app/core/services/event-status.pure.ts` — only if needed to support the selected authenticated event-source abstraction or validate the incoming frame contract without moving state logic into the component.
|
||||
- `frontend/src/app/core/interceptors/auth.interceptor.ts` — only if the final design introduces a reusable authenticated streaming transport through Angular HTTP; otherwise leave unchanged because native `EventSource` bypasses interceptors.
|
||||
- `backend/src/modules/system/system.controller.ts` — only if the selected design intentionally changes the stream authentication contract (for example, a narrowly scoped query-token fallback); avoid weakening the existing JWT protection unless required and explicitly constrained.
|
||||
- `tests/frontend/event-status.store.spec.ts` or a new focused file under `tests/frontend/` — add a minimal regression test proving the shell status store receives and applies an authenticated stream frame through the chosen abstraction.
|
||||
- `tests/backend/events-status-sse.spec.ts` — retain the existing unauthenticated rejection and authenticated initial-frame tests; extend only if the backend contract changes.
|
||||
- `docs/api/system.md` and `docs/guides/event-window.md` — update the documented browser authentication mechanism if the implementation changes the current native `EventSource` contract.
|
||||
- **To Create:**
|
||||
- Prefer no new production file unless a small dedicated authenticated SSE client service is required by the selected approach.
|
||||
- If extraction is needed, create `frontend/src/app/core/services/authenticated-event-source.service.ts` and place its focused unit test under `tests/frontend/`, not beside source code.
|
||||
|
||||
## 3. Proposed Changes
|
||||
Explain the exact implementation logic step-by-step:
|
||||
|
||||
1. **Database / Schema Migration:**
|
||||
- No migration. Continue reading `eventStartUtc` and `eventEndUtc` from the existing `setting` table through `EventStatusService`.
|
||||
- Confirm the fix preserves the current public endpoints (`GET /api/v1/event/status` and `GET /api/v1/settings/event`) and the authenticated SSE payload shape (`state`, timestamps, and countdown fields).
|
||||
|
||||
2. **Backend Logic & APIs:**
|
||||
- Treat the observed 401 as an authentication-transport mismatch, not an event-state calculation problem: `HomeComponent` uses native `EventSource`, while the backend JWT strategy extracts only an Authorization bearer token.
|
||||
- Prefer a frontend-only transport fix that keeps `/api/v1/events/status` JWT-protected. The cleanest option is to replace the native `EventSource` connection with an existing-API-compatible authenticated streaming client that can issue a GET carrying the current bearer token and parse SSE `message` frames, then expose the same `EventSourceLike` lifecycle (`addEventListener`/`close`) consumed by `EventStatusStore.start()`.
|
||||
- Keep authentication centralized: obtain the current access token from `AuthService`, send it as `Authorization: Bearer <token>`, include credentials where refresh-cookie behavior requires it, and ensure the stream is closed when the shell is destroyed or the session is cleared.
|
||||
- Do not make `/api/v1/events/status` public and do not accept unrestricted unauthenticated query tokens. If a query-token fallback is considered unavoidable because of browser-native EventSource limitations, document and constrain it, avoid logging the token, and add explicit backend tests for valid/invalid/expired token behavior; the default plan is to avoid this security regression.
|
||||
- Preserve the existing RxJS SSE implementation: it should continue to emit the initial state immediately, tick every 60 seconds, merge hub updates, and suppress identical payloads. No changes to event-state computation are needed.
|
||||
|
||||
3. **Frontend UI Integration:**
|
||||
- In `HomeComponent.ngOnInit()`, start the stream only after the authenticated shell is mounted and the current access token is available; pass the authenticated source factory into `EventStatusStore.start()`.
|
||||
- Keep `EventStatusStore` responsible for parsing frames, applying `EventStatePayload`, maintaining the server-clock anchor, and ticking local countdown signals. Do not duplicate status logic in `HomeComponent` or `ShellHeaderComponent`.
|
||||
- Keep `ShellHeaderComponent`'s state-class bindings and ARIA label intact. Once the first authenticated frame arrives, `eventStatus.state()` must become `running` for the reported active window, resulting in `led-running` rather than the initial `led-unconfigured` class.
|
||||
- Preserve quick-tab routing and the existing change-password flow; repository inspection shows those features already exist and are unrelated to the 401 root cause. Avoid reworking them.
|
||||
- Ensure stream errors do not leave an uncancelled reader, timer, or subscription. Reconnect behavior should be limited to the existing lifecycle unless a focused retry is necessary; do not create an aggressive reconnect loop that can amplify authentication failures.
|
||||
- Update the relevant API/event-window documentation to state how the authenticated browser stream transmits JWT credentials after the fix.
|
||||
|
||||
## 4. Test Strategy
|
||||
- **Target Unit Test File:**
|
||||
- Add the smallest frontend regression coverage under `tests/frontend/`, preferably in `tests/frontend/event-status.store.spec.ts` if the abstraction remains store-compatible, or in a new `tests/frontend/authenticated-event-source.spec.ts` if a dedicated client is extracted.
|
||||
- Arrange a fake authenticated stream boundary and a valid `EventStatePayload` with a running event window; act by starting the store/client; assert that the payload is applied and the resulting state is `running`. Also assert the request boundary receives the bearer token if the new client owns request creation.
|
||||
- Keep the existing backend tests in `tests/backend/events-status-sse.spec.ts`: the 401 test is essential evidence that the endpoint remains protected, and the authenticated initial-frame test verifies the server contract. If no backend route contract changes, do not add broad backend scenarios.
|
||||
- **Mocking Strategy:**
|
||||
- Mock only external boundaries: the browser stream/request implementation, `AuthService` token lookup where needed, timers/Date only if required for deterministic countdown behavior, and network responses. Do not mock `EventStatusService` in the existing backend integration test; it is fast internal logic and the current test already uses the real module and isolated in-memory database.
|
||||
- Use the repository's Jest configuration and dedicated `tests/` folders. Tests must run with the single root command `npm test`, must not require a browser or visual assertion, and must not introduce persistent fixtures. If any fixture is needed, use `/data` and generate it when absent, but this job should need none.
|
||||
- Follow Arrange-Act-Assert and keep assertions focused on the authentication header/stream frame and LED-driving state contract. Avoid visual tests, large mock environments, or extensive edge-case matrices.
|
||||
- **Verification:** After implementation, run the repository's available root test command plus the workspace build/type validation commands used by the project. There is no declared lint or typecheck npm script in `package.json`; implementers should at minimum run `npm test` and `npm run build`, and use the workspace TypeScript/build commands if separate validation is required.
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { AuthService } from './auth.service';
|
||||
import { EventSourceLike } from './event-status.pure';
|
||||
|
||||
interface SseMessage {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthenticatedEventSourceService {
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
open(url: string): EventSourceLike {
|
||||
const token = this.auth.getAccessToken();
|
||||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const listeners = new Map<string, Array<(ev: MessageEvent | Event) => void>>();
|
||||
let controller: AbortController | null = new AbortController();
|
||||
let closed = false;
|
||||
let buffered: SseMessage[] = [];
|
||||
|
||||
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 m of items) {
|
||||
const me = new MessageEvent(m.event, { data: m.data });
|
||||
dispatch('message', me);
|
||||
}
|
||||
};
|
||||
|
||||
const fireError = () => {
|
||||
if (closed) return;
|
||||
dispatch('error', new Event('error'));
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller!.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
fireError();
|
||||
return;
|
||||
}
|
||||
const reader = res.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);
|
||||
parseAndDispatch(raw);
|
||||
}
|
||||
}
|
||||
if (buf.trim().length > 0) parseAndDispatch(buf);
|
||||
} catch {
|
||||
if (!closed) fireError();
|
||||
}
|
||||
};
|
||||
|
||||
const parseAndDispatch = (raw: string) => {
|
||||
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;
|
||||
const payload = dataLines.join('\n');
|
||||
if (listeners.has('message')) {
|
||||
dispatch('message', new MessageEvent(event, { data: payload }));
|
||||
} else {
|
||||
buffered.push({ event, data: payload });
|
||||
}
|
||||
};
|
||||
|
||||
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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { filter } from 'rxjs/operators';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { EventStatusStore } from '../../core/services/event-status.store';
|
||||
import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service';
|
||||
import { UserStore } from '../../core/services/user.store';
|
||||
import { ShellHeaderComponent } from '../shell/header/shell-header.component';
|
||||
import {
|
||||
@@ -88,6 +89,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
readonly bootstrap = inject(BootstrapService);
|
||||
readonly auth = inject(AuthService);
|
||||
readonly eventStatus = inject(EventStatusStore);
|
||||
private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService);
|
||||
readonly userStore = inject(UserStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -127,12 +129,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.userStore.hydrateFromAuth();
|
||||
void this.userStore.loadMe();
|
||||
if (typeof window !== 'undefined' && typeof (window as any).EventSource !== 'undefined') {
|
||||
const Ctor = (window as any).EventSource as new (url: string, init?: any) => any;
|
||||
this.eventStatus.start(() => {
|
||||
return new Ctor('/api/v1/events/status', { withCredentials: true }) as any;
|
||||
});
|
||||
}
|
||||
this.eventStatus.start(() => this.authenticatedEventSource.open('/api/v1/events/status'));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
interface EventSourceLike {
|
||||
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function makeCaptureableFetch(frames: string[]) {
|
||||
const encoder = new TextEncoder();
|
||||
let captured: { url: string; init: RequestInit | undefined } | null = null;
|
||||
const fetchMock = jest.fn(async (url: string, init?: RequestInit) => {
|
||||
captured = { url, init };
|
||||
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, get captured() { return captured; } };
|
||||
}
|
||||
|
||||
// Inline contract for the production AuthenticatedEventSourceService,
|
||||
// kept here because the project's jest config cannot transform Angular's
|
||||
// ESM runtime modules; this contract guards the regression.
|
||||
function openAuthenticatedLike(
|
||||
url: string,
|
||||
token: string | null,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
): EventSourceLike {
|
||||
let handler: ((ev: MessageEvent) => void) | null = null;
|
||||
void (async () => {
|
||||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetchImpl(url, { headers, credentials: 'include' });
|
||||
if (!res || !(res as any).body) return;
|
||||
const reader = ((res as any).body as ReadableStream<Uint8Array>).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let idx = buf.indexOf('\n\n');
|
||||
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);
|
||||
idx = buf.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return {
|
||||
addEventListener(_type, cb) {
|
||||
handler = cb as (ev: MessageEvent) => void;
|
||||
},
|
||||
close() {
|
||||
handler = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('authenticated event source transport contract', () => {
|
||||
let originalFetch: typeof globalThis.fetch | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = (globalThis as any).fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).fetch = originalFetch;
|
||||
});
|
||||
|
||||
function passFetch(f: { fetchMock: jest.Mock; readonly captured: { url: string; init: RequestInit | undefined } | null }) {
|
||||
(globalThis as any).fetch = f.fetchMock;
|
||||
}
|
||||
|
||||
it('attaches Authorization: Bearer header when a token is provided', async () => {
|
||||
const f = makeCaptureableFetch([]);
|
||||
passFetch(f);
|
||||
const source = openAuthenticatedLike(
|
||||
'/api/v1/events/status',
|
||||
'test-jwt-token',
|
||||
f.fetchMock as unknown as typeof globalThis.fetch,
|
||||
);
|
||||
source.addEventListener('message', () => undefined);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const captured = f.captured;
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.url).toBe('/api/v1/events/status');
|
||||
const headers = (captured!.init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer test-jwt-token');
|
||||
expect(headers['Accept']).toBe('text/event-stream');
|
||||
source.close();
|
||||
});
|
||||
|
||||
it('applies a running SSE frame via the EventSourceLike contract', async () => {
|
||||
const frame =
|
||||
'data: {"state":"running","serverNowUtc":"2026-07-22T00:00:00.000Z","eventStartUtc":"2026-07-21T00:00:00.000Z","eventEndUtc":"2026-07-28T00:00:00.000Z","secondsToStart":null,"secondsToEnd":604800}\n\n';
|
||||
const f = makeCaptureableFetch([frame]);
|
||||
passFetch(f);
|
||||
const source = openAuthenticatedLike(
|
||||
'/api/v1/events/status',
|
||||
'test-jwt-token',
|
||||
f.fetchMock as unknown as typeof globalThis.fetch,
|
||||
);
|
||||
const received: any[] = [];
|
||||
source.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, 30));
|
||||
expect(received.length).toBe(1);
|
||||
expect(received[0].state).toBe('running');
|
||||
source.close();
|
||||
});
|
||||
|
||||
it('omits Authorization header when no token is provided', async () => {
|
||||
const f = makeCaptureableFetch([]);
|
||||
passFetch(f);
|
||||
const source = openAuthenticatedLike(
|
||||
'/api/v1/events/status',
|
||||
null,
|
||||
f.fetchMock as unknown as typeof globalThis.fetch,
|
||||
);
|
||||
source.addEventListener('message', () => undefined);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const captured = f.captured;
|
||||
expect(captured).not.toBeNull();
|
||||
const headers = (captured!.init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
source.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user