Merge pull request '[SPA][P12] Harden Angular host/player route session guards (#191)' (#195) from feat/191-angular-route-session-guards into main
All checks were successful
CI / test-and-quality (push) Successful in 1m51s

This commit was merged in pull request #195.
This commit is contained in:
2026-03-01 17:48:25 +01:00
2 changed files with 122 additions and 4 deletions

View File

@@ -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<string, string | null>, query: Record<string, string | null> = {}): RouteLike {
return {
paramMap: { get: (key: string) => params[key] ?? null },
queryParamMap: { get: (key: string) => query[key] ?? null },
};
}
function storageMock(initial: Record<string, string> = {}): Storage {
const data = new Map<string, string>(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');
});
});

View File

@@ -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<RouteSessionContext> = (route)
if (code) {
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
}
return { sessionCode: code };
return { sessionCode: code, playerId: null, token: null };
};
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => ({
sessionCode: resolveSessionCode(route, 'player'),
});
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (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,
};
};