diff --git a/.kilo/plans/880.md b/.kilo/plans/880.md deleted file mode 100644 index b5d78b0..0000000 --- a/.kilo/plans/880.md +++ /dev/null @@ -1,30 +0,0 @@ -# Implementation Plan: Authenticated Shell LED Visibility Fix - -## 1. Architectural Reconnaissance -- **Codebase style & conventions:** Node.js monorepo with an Angular 17 standalone-component SPA and NestJS backend. The authenticated shell uses OnPush components, signal inputs/outputs, inline Angular templates/styles, and theme CSS custom properties. `HomeComponent` owns event-stream lifecycle and passes `EventStatusStore.state()` into the presentational `ShellHeaderComponent`. Quick tabs and change-password behavior are already implemented and are not implicated in this regression. -- **Data Layer:** TypeORM-backed backend settings provide `eventStartUtc` and `eventEndUtc`; no schema or persistence changes are required. The backend event-state calculation and authenticated SSE payload are already functioning: the shell receives the correct `running`, `countdown`, `stopped`, or `unconfigured` state and applies the matching class. -- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from the dedicated `tests/frontend/` directory. `npm test` runs all backend and frontend tests, while `npm run test:frontend` runs the frontend project only. Existing `tests/frontend/shell-led.spec.ts` is a source-structure assertion and does not instantiate the component or verify the rendered style binding. -- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, persistent `/data` assets, or `setup.sh` changes are required. Existing Angular, Jest, jsdom, and TypeScript dependencies are sufficient. - -## 2. Impacted Files -- **To Modify:** - - `frontend/src/app/features/shell/header/shell-header.component.ts` — remove the conflicting transparent base declaration and bind the state-specific background color directly on the LED element so the browser paints the themed color reliably. - - `tests/frontend/shell-led.spec.ts` — replace/extend brittle source-only assertions with a focused automated regression covering the style value produced for each event state and preserving the accessible state label. -- **To Create:** None. - -## 3. Proposed Changes -1. **Database / Schema Migration:** None. Event timestamps, settings storage, REST/SSE contracts, and backend state computation remain unchanged. -2. **Backend Logic & APIs:** None. `/api/v1/events/status` already emits the correct state, and `EventStatusStore` already forwards it to the shell. -3. **Frontend UI Integration:** - 1. In `ShellHeaderComponent`, define a typed state-to-theme-color mapping for all `EventState` values: `running` → `var(--color-success)`, `countdown` → `var(--color-warning)`, `stopped` → `var(--color-danger)`, and `unconfigured` → `var(--color-secondary)`. - 2. Replace the unused `ledClass` computed value with a computed background-color value derived from `eventState()`; keep the existing state class bindings because they are useful selectors and preserve the current DOM contract. - 3. Bind the computed value directly to the LED span's `style.background-color`. This gives the element an explicit non-transparent author value while continuing to resolve the active theme token at runtime, matching the reported proof that a direct targeted declaration makes the dot visible. - 4. Remove `background-color: transparent` from the component base `.led` rule, and remove the redundant component state-color declarations if the inline binding becomes the single authoritative color source. Retain only the shape/layout styling (`inline-block`, 10×10 dimensions, circular radius, border, alignment). - 5. Remove the global LED state rules from `frontend/src/styles.css` if they are no longer consumed, avoiding duplicate ownership and future cascade ambiguity; keep canonical theme token declarations untouched. - 6. Preserve the existing `data-testid`, four state classes, and `aria-label="Event status: "`, so status remains exposed textually rather than by color alone. - 7. Do not modify `HomeComponent`, `QuickTabsComponent`, change-password components, event store, routes, SSE transport, or backend services because repository tracing shows those layers already deliver the correct state and shell behavior. - -## 4. Test Strategy -- **Target Unit Test File:** `tests/frontend/shell-led.spec.ts`. -- **Mocking Strategy:** No backend, HTTP, SSE, timers, filesystem fixtures, browser automation, or persistent data. Exercise a small exported pure state-to-color helper (or equivalent component mapping) with table-driven assertions for the four states, and retain focused source/template assertions that the LED consumes that value through `[style.background-color]`, keeps each state class binding, and keeps the dynamic `aria-label`. This directly prevents a regression to a transparent base color while remaining fast and CLI-only under `npm test`. -- **Verification Commands for the implementer:** Run `npm run test:frontend`, `npm test`, and `npm run build` from `/repo`. The root build compiles both Angular and NestJS; no separate lint or typecheck script is defined. diff --git a/.kilo/plans/881.md b/.kilo/plans/881.md new file mode 100644 index 0000000..f1ba835 --- /dev/null +++ b/.kilo/plans/881.md @@ -0,0 +1,45 @@ +# Implementation Plan: Job 881 — Cross-Tab Authenticated Shell Invalidation + +## 1. Architectural Reconnaissance +- **Codebase style & conventions:** TypeScript monorepo with an Angular standalone-component SPA and NestJS API. Frontend services are root-provided Angular injectables using signals, `inject()`, async/await, and typed HTTP calls. Authentication state is held in `AuthService` signals and mirrored to per-tab `sessionStorage`; `HomeComponent` owns the authenticated shell and starts/stops the authenticated event stream. The route tree protects `/`, `/challenges`, `/scoreboard`, and `/blog` through the parent `authGuard`. +- **Data Layer:** No database/schema change is required. The server-side logout endpoint already revokes/clears the refresh-cookie state; this job is a browser-context propagation fix. Existing frontend persistence uses `sessionStorage`, which is intentionally tab-scoped and therefore cannot notify sibling tabs. A cross-tab browser event channel should carry an invalidation notification without carrying access tokens or user data. +- **Test Framework & Structure:** Root Jest 29 with two projects in `tests/jest.config.js`; frontend tests run in jsdom through `npm test` or `npm run test:frontend`. Tests are already centralized under `tests/frontend`, never beside source. Existing tests are mostly focused TypeScript/pure-contract tests, and `authenticated-event-source.spec.ts` models fetch/SSE boundaries because Angular runtime modules are difficult for the current Jest transform. Add only small logic tests for the notification/listener behavior and source error handling. +- **Required Tools & Dependencies:** No new system tools, npm packages, database migrations, or `setup.sh` changes are expected. `BroadcastChannel` and/or the `storage` event are browser platform APIs. The implementation should feature-detect the selected API and remain safe in jsdom/SSR-like contexts. Existing Node/npm/Jest/TypeScript setup is sufficient. + +## 2. Impacted Files +- **To Modify:** + - `frontend/src/app/core/services/auth.service.ts` — publish a same-browser-context session-invalidation event after local logout/clear and subscribe to peer-tab invalidation events so every tab clears its in-memory and tab-local state. + - `frontend/src/app/core/services/authenticated-event-source.service.ts` — distinguish an unauthorized SSE response (`401`, and optionally other auth-failure statuses according to the existing API contract) from generic transport failure and expose it through the existing `error` event contract, or otherwise provide a single auth-invalidation callback path to `AuthService`. + - `frontend/src/app/core/services/event-status.store.ts` — register an `error` listener for the authenticated event source and invoke the auth invalidation path on unauthorized stream failure; ensure `stop()` removes/neutralizes the listener and remains idempotent. + - `frontend/src/app/features/home/home.component.ts` — react to the centralized auth state becoming unauthenticated, stop/reset shell-owned state as needed, and navigate to `/login` without requiring a quick-tab interaction or reload; avoid duplicate navigation when the local logout already performed it. +- **To Create:** + - `tests/frontend/auth-cross-tab.spec.ts` — focused tests for cross-tab invalidation publication/consumption and duplicate/self-origin behavior using mocked browser channel/storage boundaries. + - If the implementer chooses a pure transport result rather than a direct callback, optionally `frontend/src/app/core/services/auth-session-events.pure.ts` and its corresponding test file; prefer keeping the event-name/payload validation pure and small rather than introducing an unnecessary abstraction. +- **Not to Modify:** Backend controllers, refresh-token schema/migrations, shell markup, quick-tab components, routes, or `setup.sh`; the server invalidation and route protection already exist. + +## 3. Proposed Changes +1. **Database / Schema Migration:** + - No migration. Confirm the existing `POST /api/v1/auth/logout` behavior remains the source of truth for server-side refresh-cookie invalidation. + - Do not broadcast JWTs, refresh tokens, usernames, or arbitrary storage contents. Broadcast only a fixed event type (for example, a namespaced `logout`/`session-invalidated` message) and optionally a random per-tab sender identifier if needed to avoid self-processing. + +2. **Backend Logic & APIs:** + - No backend endpoint changes. The existing authenticated logout endpoint is called by `AuthService.logout()` before local state is cleared, and hard navigation already proves the server clears the refresh cookie. + - Treat an authenticated SSE response with `401` as evidence that the current tab’s session is invalid. Update `AuthenticatedEventSourceService`’s internal fetch path so the response status is available to the existing `error` dispatch, without exposing response bodies or credentials. Generic network/stream errors should remain generic unless the response explicitly indicates unauthorized. + - If the transport contract cannot safely distinguish error categories through `Event`, add a minimal typed error event/detail or a separate `unauthorized` listener type in `event-status.pure.ts`; update all implementations and tests consistently. Keep the contract framework-light and compatible with the current custom fetch-based EventSource implementation. + +3. **Frontend UI Integration:** + - Centralize cross-tab handling in `AuthService`, because it owns the authoritative `isAuthenticated`, access-token, and session-storage state. Initialize one browser listener for the root singleton, feature-detect `BroadcastChannel`, and provide a fallback using a namespaced `localStorage` key plus the browser `storage` event if BroadcastChannel is unavailable. If both are installed, publish on the primary channel and retain the fallback only if necessary; avoid double-processing identical notifications. + - On a local logout, call the server as today, then clear local state and publish the invalidation signal. Ensure peer-tab handling calls a non-broadcasting local-clear method so the notification does not echo indefinitely. The local tab may also receive its own channel message in some test/browser implementations, so make handling idempotent. + - On a peer invalidation, clear the access token, current user, and `sessionStorage`; make the state transition observable to consumers. If `AuthService.clear()` is reused by refresh failure or other local failures, define whether those paths should broadcast as well; the preferred rule is that any confirmed local session invalidation broadcasts, while a peer-originated clear suppresses rebroadcast. + - Wire the authenticated SSE lifecycle to the same invalidation path. `EventStatusStore.start()` should attach both message and error listeners. When the authenticated event source reports `401`, invoke an injected/session-invalidation callback or call an explicit `AuthService` method; then close the stream. Do not redirect merely for transient network errors, malformed frames, or ordinary stream closure. + - In `HomeComponent`, observe the auth signal or subscribe to a narrow invalidation event and navigate to `/login` after a peer logout/SSE unauthorized result. Reset `UserStore`, close the event stream, close open menus/modals if appropriate, and use a navigation guard/flag so the local `onLogout()` path does not trigger duplicate navigation. The next click on Challenges, Scoreboard, or Blog must not be able to continue rendering authenticated content; the route guard should see `isAuthenticated === false` and return the existing login `UrlTree`. + - Ensure cleanup: close `BroadcastChannel`, remove `storage` listeners, and detach/neutralize SSE listeners during service/component destruction or `stop()`. Preserve the existing event countdown behavior and shell UI when authentication remains valid. + - Keep browser API access guarded for non-browser/test environments. Use the existing Angular DI/signal conventions; do not add a third-party messaging library or move tokens into `localStorage`. + +## 4. Test Strategy +- **Target Unit Test File:** `tests/frontend/auth-cross-tab.spec.ts` for the core event protocol and AuthService behavior. Extend `tests/frontend/authenticated-event-source.spec.ts` only with the minimal unauthorized-response contract test if that file remains the established transport boundary. If direct Angular service instantiation is impractical under the current Jest setup, extract a small pure event encoder/decoder and test it directly, while testing the transport contract with the existing fetch mock style. +- **Mocking Strategy:** + - Mock `BroadcastChannel` with an in-memory registry of channel instances, recording `postMessage`, `addEventListener`, `removeEventListener`, and `close`; deliver messages only to peer instances to model browser behavior and separately cover a self-delivery implementation. + - Mock `window.localStorage` and dispatch synthetic `StorageEvent` objects for the fallback path. Assert the implementation only accepts the fixed namespaced key/event shape and ignores malformed or unrelated storage changes. + - Use a minimal fake `AuthService` or explicit test doubles for token/user/session-storage operations where testing `EventStatusStore`; use a fake `EventSourceLike` that records listeners and close calls to verify `401` causes invalidation and generic errors do not. + - Cover only the core success and directly relevant failure paths: local logout publishes once and clears state; peer notification clears state without rebroadcast; fallback storage notification works when BroadcastChannel is absent; malformed/unrelated messages are ignored; unauthorized SSE invalidates and closes; generic SSE error preserves authentication. Run through the existing root `npm test` command; no browser/UI or persistent `/data` fixture is needed. diff --git a/frontend/src/app/core/services/auth-session-events.pure.ts b/frontend/src/app/core/services/auth-session-events.pure.ts new file mode 100644 index 0000000..b14f98a --- /dev/null +++ b/frontend/src/app/core/services/auth-session-events.pure.ts @@ -0,0 +1,43 @@ +export const CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1'; +export const CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1'; +export const CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated'; + +export interface CrossTabInvalidationMessage { + type: 'session-invalidated'; + origin: string; + ts: number; +} + +export function encodeInvalidationMessage(origin: string): CrossTabInvalidationMessage { + return { + type: 'session-invalidated', + origin, + ts: Date.now(), + }; +} + +export function isCrossTabInvalidationMessage( + value: unknown, +): value is CrossTabInvalidationMessage { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + v['type'] === 'session-invalidated' && + typeof v['origin'] === 'string' && + typeof v['ts'] === 'number' + ); +} + +export function isStorageInvalidationEvent( + event: { key?: string | null; newValue?: string | null } | null | undefined, +): boolean { + if (!event) return false; + if (event.key !== CROSS_TAB_STORAGE_KEY) return false; + if (event.newValue == null) return false; + try { + const parsed = JSON.parse(event.newValue) as unknown; + return isCrossTabInvalidationMessage(parsed); + } catch { + return false; + } +} diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index 61fe496..58c591f 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal, computed, inject } from '@angular/core'; +import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { @@ -7,6 +7,14 @@ import { clearStoredSession, StoredSession, } from './auth.session-storage'; +import { + CROSS_TAB_CHANNEL_NAME, + CROSS_TAB_STORAGE_KEY, + CrossTabInvalidationMessage, + encodeInvalidationMessage, + isCrossTabInvalidationMessage, + isStorageInvalidationEvent, +} from './auth-session-events.pure'; export interface CurrentUser { id: string; @@ -78,9 +86,22 @@ export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure @Injectable({ providedIn: 'root' }) export class AuthService { private http: HttpClient; + private readonly destroyRef = inject(DestroyRef); + + private readonly crossTabOrigin = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`; + private channel: BroadcastChannel | null = null; + private storageListener: ((ev: StorageEvent) => void) | null = null; + private suppressBroadcast = false; + + private peerInvalidationListeners = new Set<(reason: 'peer-logout' | 'sse-unauthorized') => void>(); constructor(http?: HttpClient) { this.http = http ?? inject(HttpClient); + this.installCrossTabListener(); + this.destroyRef.onDestroy(() => this.teardownCrossTabListener()); } private accessToken = signal(null); @@ -158,7 +179,7 @@ export class AuthService { } catch { // ignore network/auth errors; we still clear local state } - this.clear(); + this.clearAndBroadcast(); } async me(): Promise { @@ -204,10 +225,118 @@ export class AuthService { clearStoredSession(this.sessionStorage()); } + clearAndBroadcast(): void { + this.clear(); + if (this.suppressBroadcast) return; + const message = encodeInvalidationMessage(this.crossTabOrigin); + this.publishInvalidation(message); + } + + handleSseUnauthorized(): void { + this.clearAndBroadcast(); + this.notifyPeerInvalidation('sse-unauthorized'); + } + + onPeerInvalidation(cb: (reason: 'peer-logout' | 'sse-unauthorized') => void): () => void { + this.peerInvalidationListeners.add(cb); + return () => this.peerInvalidationListeners.delete(cb); + } + getAccessToken(): string | null { return this.accessToken(); } + private publishInvalidation(message: CrossTabInvalidationMessage): void { + if (this.channel) { + try { + this.channel.postMessage(message); + } catch { + // ignore serialization failures; the storage fallback will still fire + } + } + if (typeof window !== 'undefined' && window.localStorage) { + try { + window.localStorage.setItem(CROSS_TAB_STORAGE_KEY, JSON.stringify(message)); + } catch { + // ignore quota / serialization errors + } + } + } + + private installCrossTabListener(): void { + if (typeof window === 'undefined') return; + if (typeof BroadcastChannel !== 'undefined') { + try { + this.channel = new BroadcastChannel(CROSS_TAB_CHANNEL_NAME); + this.channel.addEventListener('message', this.onBroadcastMessage); + } catch { + this.channel = null; + } + } + this.storageListener = (ev: StorageEvent) => { + if (!isStorageInvalidationEvent(ev)) return; + this.handlePeerInvalidation(ev.newValue, 'peer-logout'); + }; + window.addEventListener('storage', this.storageListener); + } + + private teardownCrossTabListener(): void { + if (this.channel) { + try { + this.channel.removeEventListener('message', this.onBroadcastMessage); + this.channel.close(); + } catch { + // ignore + } + this.channel = null; + } + if (this.storageListener && typeof window !== 'undefined') { + try { + window.removeEventListener('storage', this.storageListener); + } catch { + // ignore + } + this.storageListener = null; + } + this.peerInvalidationListeners.clear(); + } + + private readonly onBroadcastMessage = (ev: MessageEvent) => { + if (!isCrossTabInvalidationMessage(ev.data)) return; + if (ev.data.origin === this.crossTabOrigin) return; + this.handlePeerInvalidation(JSON.stringify(ev.data), 'peer-logout'); + }; + + private handlePeerInvalidation( + raw: string | null, + reason: 'peer-logout' | 'sse-unauthorized', + ): void { + if (raw) { + try { + const parsed = JSON.parse(raw) as unknown; + if (!isCrossTabInvalidationMessage(parsed)) return; + if (parsed.origin === this.crossTabOrigin) return; + } catch { + return; + } + } + if (!this.isAuthenticated()) return; + this.suppressBroadcast = true; + this.clear(); + this.suppressBroadcast = false; + this.notifyPeerInvalidation(reason); + } + + private notifyPeerInvalidation(reason: 'peer-logout' | 'sse-unauthorized'): void { + for (const cb of this.peerInvalidationListeners) { + try { + cb(reason); + } catch { + // ignore listener errors + } + } + } + private sessionStorage(): Storage { return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage); } diff --git a/frontend/src/app/core/services/authenticated-event-source.service.ts b/frontend/src/app/core/services/authenticated-event-source.service.ts index e726a68..ec0a726 100644 --- a/frontend/src/app/core/services/authenticated-event-source.service.ts +++ b/frontend/src/app/core/services/authenticated-event-source.service.ts @@ -48,6 +48,11 @@ export class AuthenticatedEventSourceService { dispatch('error', new Event('error')); }; + const fireUnauthorized = () => { + if (closed) return; + dispatch('unauthorized', new Event('unauthorized')); + }; + const start = async () => { try { const res = await fetch(url, { @@ -56,6 +61,10 @@ export class AuthenticatedEventSourceService { credentials: 'include', signal: controller!.signal, }); + if (res.status === 401 || res.status === 403) { + if (!closed) fireUnauthorized(); + return; + } if (!res.ok || !res.body) { fireError(); return; diff --git a/frontend/src/app/core/services/event-status.pure.ts b/frontend/src/app/core/services/event-status.pure.ts index a3cb986..65dbbcb 100644 --- a/frontend/src/app/core/services/event-status.pure.ts +++ b/frontend/src/app/core/services/event-status.pure.ts @@ -10,7 +10,10 @@ export interface EventStatePayload { } export interface EventSourceLike { - addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void; + addEventListener( + type: 'open' | 'message' | 'error' | 'unauthorized', + listener: (ev: MessageEvent | Event) => void, + ): void; close(): void; } diff --git a/frontend/src/app/core/services/event-status.store.ts b/frontend/src/app/core/services/event-status.store.ts index 379e59a..2a077b8 100644 --- a/frontend/src/app/core/services/event-status.store.ts +++ b/frontend/src/app/core/services/event-status.store.ts @@ -66,7 +66,7 @@ export class EventStatusStore { this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime()); } - start(createSource: () => EventSourceLike): void { + start(createSource: () => EventSourceLike, onUnauthorized?: () => void): void { this.stop(); const src = createSource(); this.source = src; @@ -81,6 +81,15 @@ export class EventStatusStore { // ignore malformed frame } }); + if (onUnauthorized) { + src.addEventListener('unauthorized', () => { + try { + onUnauthorized(); + } catch { + // ignore + } + }); + } if (!this.intervalId) { this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000); } diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index 64a4d20..e5adc18 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -129,11 +129,21 @@ export class HomeComponent implements OnInit, OnDestroy { ngOnInit(): void { this.userStore.hydrateFromAuth(); void this.userStore.loadMe(); - this.eventStatus.start(() => this.authenticatedEventSource.open('/api/v1/events/status')); + this.eventStatus.start( + () => this.authenticatedEventSource.open('/api/v1/events/status'), + () => this.handleSessionInvalidated('sse-unauthorized'), + ); + this.peerInvalidationUnsubscribe = this.auth.onPeerInvalidation((reason) => + this.handleSessionInvalidated(reason), + ); } ngOnDestroy(): void { this.eventStatus.stop(); + if (this.peerInvalidationUnsubscribe) { + this.peerInvalidationUnsubscribe(); + this.peerInvalidationUnsubscribe = null; + } } goHome(): void { @@ -192,4 +202,23 @@ export class HomeComponent implements OnInit, OnDestroy { this.userStore.reset(); await this.router.navigateByUrl('/login'); } + + private peerInvalidationUnsubscribe: (() => void) | null = null; + private navigatingToLogin = false; + + private async handleSessionInvalidated( + _reason: 'peer-logout' | 'sse-unauthorized', + ): Promise { + this.userStore.reset(); + this.changePasswordOpen.set(false); + this.userMenuOpen.set(false); + if (this.navigatingToLogin) return; + if (this.router.url.startsWith('/login')) return; + this.navigatingToLogin = true; + try { + await this.router.navigateByUrl('/login'); + } finally { + this.navigatingToLogin = false; + } + } } diff --git a/tests/frontend/auth-session-events.spec.ts b/tests/frontend/auth-session-events.spec.ts new file mode 100644 index 0000000..9c1fce9 --- /dev/null +++ b/tests/frontend/auth-session-events.spec.ts @@ -0,0 +1,93 @@ +import { + CROSS_TAB_CHANNEL_NAME, + CROSS_TAB_STORAGE_KEY, + CrossTabInvalidationMessage, + encodeInvalidationMessage, + isCrossTabInvalidationMessage, + isStorageInvalidationEvent, +} from '../../frontend/src/app/core/services/auth-session-events.pure'; + +describe('auth-session-events.pure', () => { + describe('encodeInvalidationMessage', () => { + it('produces a session-invalidated message with origin and timestamp', () => { + const before = Date.now(); + const msg = encodeInvalidationMessage('tab-A'); + const after = Date.now(); + expect(msg.type).toBe('session-invalidated'); + expect(msg.origin).toBe('tab-A'); + expect(msg.ts).toBeGreaterThanOrEqual(before); + expect(msg.ts).toBeLessThanOrEqual(after); + }); + }); + + describe('isCrossTabInvalidationMessage', () => { + it('accepts a properly shaped message', () => { + const msg: CrossTabInvalidationMessage = { + type: 'session-invalidated', + origin: 'x', + ts: 1, + }; + expect(isCrossTabInvalidationMessage(msg)).toBe(true); + }); + + it('rejects non-objects', () => { + expect(isCrossTabInvalidationMessage(null)).toBe(false); + expect(isCrossTabInvalidationMessage(undefined)).toBe(false); + expect(isCrossTabInvalidationMessage('string')).toBe(false); + expect(isCrossTabInvalidationMessage(42)).toBe(false); + }); + + it('rejects objects with wrong type or missing fields', () => { + expect(isCrossTabInvalidationMessage({ type: 'other', origin: 'a', ts: 1 })).toBe(false); + expect(isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 1, ts: 1 })).toBe( + false, + ); + expect(isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 'a' })).toBe( + false, + ); + expect( + isCrossTabInvalidationMessage({ type: 'session-invalidated', origin: 'a', ts: 'now' }), + ).toBe(false); + }); + }); + + describe('isStorageInvalidationEvent', () => { + it('accepts a well-formed storage event for the namespaced key', () => { + const ev = { + key: CROSS_TAB_STORAGE_KEY, + newValue: JSON.stringify(encodeInvalidationMessage('tab-X')), + }; + expect(isStorageInvalidationEvent(ev)).toBe(true); + }); + + it('rejects events with a different key', () => { + const ev = { + key: 'other.key', + newValue: JSON.stringify(encodeInvalidationMessage('tab-X')), + }; + expect(isStorageInvalidationEvent(ev)).toBe(false); + }); + + it('rejects events with no newValue (cleared key)', () => { + const ev = { key: CROSS_TAB_STORAGE_KEY, newValue: null }; + expect(isStorageInvalidationEvent(ev)).toBe(false); + }); + + it('rejects malformed JSON payloads', () => { + const ev = { key: CROSS_TAB_STORAGE_KEY, newValue: 'not-json' }; + expect(isStorageInvalidationEvent(ev)).toBe(false); + }); + + it('rejects nullish events', () => { + expect(isStorageInvalidationEvent(null)).toBe(false); + expect(isStorageInvalidationEvent(undefined)).toBe(false); + }); + }); + + describe('constant exports', () => { + it('exposes a stable channel name and storage key', () => { + expect(CROSS_TAB_CHANNEL_NAME).toBe('hipctf.auth.v1'); + expect(CROSS_TAB_STORAGE_KEY).toBe('hipctf.auth.invalidate.v1'); + }); + }); +}); diff --git a/tests/frontend/authenticated-event-source.spec.ts b/tests/frontend/authenticated-event-source.spec.ts index b452867..2bd7b38 100644 --- a/tests/frontend/authenticated-event-source.spec.ts +++ b/tests/frontend/authenticated-event-source.spec.ts @@ -13,7 +13,10 @@ if (typeof (globalThis as any).TextDecoder === 'undefined') { } interface EventSourceLike { - addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void; + addEventListener( + type: 'open' | 'message' | 'error' | 'unauthorized', + listener: (ev: MessageEvent | Event) => void, + ): void; close(): void; } @@ -33,6 +36,13 @@ function makeCaptureableFetch(frames: string[]) { return { fetchMock, get captured() { return captured; } }; } +function makeUnauthorizedFetch(status: number) { + const fetchMock = jest.fn(async () => { + return { ok: false, status, body: null } as unknown as Response; + }); + return fetchMock; +} + // 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. @@ -42,12 +52,20 @@ function openAuthenticatedLike( fetchImpl: typeof globalThis.fetch, ): EventSourceLike { let handler: ((ev: MessageEvent) => void) | null = null; + let unauthorizedHandler: ((ev: Event) => void) | null = null; void (async () => { const headers: Record = { 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).getReader(); + const res = (await fetchImpl(url, { headers, credentials: 'include' })) as unknown as Response; + const status = (res as { status?: number }).status ?? 0; + if (status === 401 || status === 403) { + if (unauthorizedHandler) unauthorizedHandler(new Event('unauthorized')); + return; + } + if (!res || !(res as { body?: unknown }).body) return; + const reader = ((res as { body: ReadableStream }).body as ReadableStream< + Uint8Array + >).getReader(); const decoder = new TextDecoder(); let buf = ''; // eslint-disable-next-line no-constant-condition @@ -70,11 +88,13 @@ function openAuthenticatedLike( } })(); return { - addEventListener(_type, cb) { - handler = cb as (ev: MessageEvent) => void; + addEventListener(type, cb) { + if (type === 'unauthorized') unauthorizedHandler = cb as (ev: Event) => void; + else handler = cb as (ev: MessageEvent) => void; }, close() { handler = null; + unauthorizedHandler = null; }, }; } @@ -150,4 +170,38 @@ describe('authenticated event source transport contract', () => { expect(headers['Authorization']).toBeUndefined(); source.close(); }); + + it('fires an unauthorized event on a 401 response', async () => { + const fetchMock = makeUnauthorizedFetch(401); + (globalThis as any).fetch = fetchMock; + const source = openAuthenticatedLike( + '/api/v1/events/status', + 'test-jwt-token', + fetchMock as unknown as typeof globalThis.fetch, + ); + const received: string[] = []; + source.addEventListener('unauthorized', (ev) => { + received.push(ev.type); + }); + await new Promise((r) => setTimeout(r, 30)); + expect(received).toEqual(['unauthorized']); + source.close(); + }); + + it('fires an unauthorized event on a 403 response', async () => { + const fetchMock = makeUnauthorizedFetch(403); + (globalThis as any).fetch = fetchMock; + const source = openAuthenticatedLike( + '/api/v1/events/status', + 'test-jwt-token', + fetchMock as unknown as typeof globalThis.fetch, + ); + const received: string[] = []; + source.addEventListener('unauthorized', (ev) => { + received.push(ev.type); + }); + await new Promise((r) => setTimeout(r, 30)); + expect(received).toEqual(['unauthorized']); + source.close(); + }); });