From 71c90109e4cbc099f8a5e3779863f807225ee578 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 16:40:12 +0000 Subject: [PATCH] feat(spa): enforce player session context in angular route guards --- .../src/app/session-route-context.spec.ts | 86 +++++++++++++++++++ .../angular/src/app/session-route-context.ts | 40 ++++++++- 2 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 frontend/angular/src/app/session-route-context.spec.ts diff --git a/frontend/angular/src/app/session-route-context.spec.ts b/frontend/angular/src/app/session-route-context.spec.ts new file mode 100644 index 0000000..a0fc0df --- /dev/null +++ b/frontend/angular/src/app/session-route-context.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { hostRouteContextResolver, playerRouteContextResolver, resolveSessionCode } from './session-route-context'; + +type RouteLike = { + paramMap: { get: (key: string) => string | null }; + queryParamMap: { get: (key: string) => string | null }; +}; + +function route(params: Record, query: Record = {}): RouteLike { + return { + paramMap: { get: (key: string) => params[key] ?? null }, + queryParamMap: { get: (key: string) => query[key] ?? null }, + }; +} + +function storageMock(initial: Record = {}): Storage { + const data = new Map(Object.entries(initial)); + + return { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + data.set(key, value); + }), + removeItem: vi.fn((key: string) => { + data.delete(key); + }), + clear: vi.fn(() => { + data.clear(); + }), + key: vi.fn((index: number) => Array.from(data.keys())[index] ?? null), + get length() { + return data.size; + }, + } as unknown as Storage; +} + +function setWindow(localStorage: Storage, sessionStorage: Storage): void { + vi.stubGlobal('window', { localStorage, sessionStorage }); +} + +describe('session route context', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('resolves player code from persisted session context when route has no code', () => { + setWindow( + storageMock({ 'wpp.session-context': JSON.stringify({ sessionCode: 'ab12', playerId: 7, token: 'tok' }) }), + storageMock() + ); + + expect(resolveSessionCode(route({}, {}) as never, 'player')).toBe('AB12'); + }); + + it('resolves host code from session query string', () => { + setWindow(storageMock(), storageMock()); + + expect(resolveSessionCode(route({}, { session: 'qwe9' }) as never, 'host')).toBe('QWE9'); + }); + + it('player resolver emits player id/token when context matches route session', () => { + setWindow( + storageMock({ 'wpp.session-context': JSON.stringify({ sessionCode: 'AB12', playerId: 5, token: 'tok-5' }) }), + storageMock() + ); + + expect(playerRouteContextResolver(route({ context: 'ab12' }) as never, {} as never)).toEqual({ + sessionCode: 'AB12', + playerId: 5, + token: 'tok-5', + }); + }); + + it('host resolver stores normalized host session code for refresh bootstrap', () => { + const sessionStorage = storageMock(); + setWindow(storageMock(), sessionStorage); + + expect(hostRouteContextResolver(route({ context: 'ab12' }) as never, {} as never)).toEqual({ + sessionCode: 'AB12', + playerId: null, + token: null, + }); + expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'AB12'); + }); +}); diff --git a/frontend/angular/src/app/session-route-context.ts b/frontend/angular/src/app/session-route-context.ts index f0819b2..449dcfb 100644 --- a/frontend/angular/src/app/session-route-context.ts +++ b/frontend/angular/src/app/session-route-context.ts @@ -5,6 +5,8 @@ import { createSessionContextStore } from '../../../src/spa/session-context-stor export interface RouteSessionContext { sessionCode: string | null; + playerId: number | null; + token: string | null; } const HOST_STORAGE_KEY = 'wpp.host-session-code'; @@ -17,6 +19,21 @@ function isCodeLike(value: string | null | undefined): value is string { return !!value && /^[A-Za-z0-9]{4,12}$/.test(value.trim()); } +function hasPlayerSessionContext(sessionCode: string): boolean { + const context = createSessionContextStore(window.localStorage).get(); + if (!context) { + return false; + } + + return ( + isCodeLike(context.sessionCode) && + normalizeCode(context.sessionCode) === normalizeCode(sessionCode) && + Number.isInteger(context.playerId) && + context.playerId > 0 && + !!context.token.trim() + ); +} + export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): string | null { const contextParam = route.paramMap.get('context'); const queryCode = route.queryParamMap.get('session'); @@ -69,6 +86,10 @@ async function requireSessionContext(route: ActivatedRouteSnapshot, mode: 'host' return false; } + if (mode === 'player' && !hasPlayerSessionContext(code)) { + return false; + } + const ok = await sessionExists(code); if (!ok) { return false; @@ -100,9 +121,20 @@ export const hostRouteContextResolver: ResolveFn = (route) if (code) { window.sessionStorage.setItem(HOST_STORAGE_KEY, code); } - return { sessionCode: code }; + return { sessionCode: code, playerId: null, token: null }; }; -export const playerRouteContextResolver: ResolveFn = (route) => ({ - sessionCode: resolveSessionCode(route, 'player'), -}); +export const playerRouteContextResolver: ResolveFn = (route) => { + const code = resolveSessionCode(route, 'player'); + const context = createSessionContextStore(window.localStorage).get(); + + if (!code || !context || normalizeCode(context.sessionCode) !== code) { + return { sessionCode: code, playerId: null, token: null }; + } + + return { + sessionCode: code, + playerId: Number.isInteger(context.playerId) && context.playerId > 0 ? context.playerId : null, + token: context.token.trim() || null, + }; +}; -- 2.39.5