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
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:
86
frontend/angular/src/app/session-route-context.spec.ts
Normal file
86
frontend/angular/src/app/session-route-context.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ import { createSessionContextStore } from '../../../src/spa/session-context-stor
|
|||||||
|
|
||||||
export interface RouteSessionContext {
|
export interface RouteSessionContext {
|
||||||
sessionCode: string | null;
|
sessionCode: string | null;
|
||||||
|
playerId: number | null;
|
||||||
|
token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOST_STORAGE_KEY = 'wpp.host-session-code';
|
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());
|
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 {
|
export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): string | null {
|
||||||
const contextParam = route.paramMap.get('context');
|
const contextParam = route.paramMap.get('context');
|
||||||
const queryCode = route.queryParamMap.get('session');
|
const queryCode = route.queryParamMap.get('session');
|
||||||
@@ -69,6 +86,10 @@ async function requireSessionContext(route: ActivatedRouteSnapshot, mode: 'host'
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'player' && !hasPlayerSessionContext(code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const ok = await sessionExists(code);
|
const ok = await sessionExists(code);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return false;
|
return false;
|
||||||
@@ -100,9 +121,20 @@ export const hostRouteContextResolver: ResolveFn<RouteSessionContext> = (route)
|
|||||||
if (code) {
|
if (code) {
|
||||||
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
|
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
|
||||||
}
|
}
|
||||||
return { sessionCode: code };
|
return { sessionCode: code, playerId: null, token: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => ({
|
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
|
||||||
sessionCode: resolveSessionCode(route, 'player'),
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user