clean routes

This commit is contained in:
2026-01-03 02:42:47 +01:00
parent 07985fb8f1
commit 2f21dc4595
107 changed files with 7596 additions and 3401 deletions

View File

@@ -63,8 +63,8 @@ export function AuthProvider({ initialSession = null, children }: AuthProviderPr
}
const target = search.toString()
? `/auth/iracing?${search.toString()}`
: '/auth/iracing';
? `/auth/login?${search.toString()}`
: '/auth/login';
router.push(target);
},
@@ -103,4 +103,4 @@ export function useAuth(): AuthContextValue {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
}

View File

@@ -0,0 +1,515 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { ReturnToSanitizer } from './ReturnToSanitizer';
import { RoutePathBuilder } from './RoutePathBuilder';
import { PathnameInterpreter } from './PathnameInterpreter';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
describe('AuthRedirectBuilder', () => {
// Mock dependencies
let mockPolicy: Mocked<RouteAccessPolicy>;
let mockSanitizer: Mocked<ReturnToSanitizer>;
let mockPathBuilder: Mocked<RoutePathBuilder>;
let mockInterpreter: Mocked<PathnameInterpreter>;
// System under test
let builder: AuthRedirectBuilder;
beforeEach(() => {
// Create mock implementations
mockPolicy = {
roleHome: vi.fn(),
roleHomeRouteId: vi.fn(),
} as any;
mockSanitizer = {
sanitizeReturnTo: vi.fn(),
} as any;
mockPathBuilder = {
build: vi.fn(),
} as any;
mockInterpreter = {
interpret: vi.fn(),
} as any;
builder = new AuthRedirectBuilder(
mockPolicy,
mockSanitizer,
mockPathBuilder,
mockInterpreter
);
});
describe('toLogin', () => {
describe('without locale', () => {
it('should build login path without locale and append returnTo', () => {
// Arrange
const currentPathname = '/dashboard';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/dashboard',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/dashboard');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/dashboard');
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'auth.login',
{},
{ locale: null }
);
expect(mockSanitizer.sanitizeReturnTo).toHaveBeenCalledWith(
'/dashboard',
'/'
);
expect(result).toBe('/auth/login?returnTo=%2Fdashboard');
});
it('should handle root path as returnTo', () => {
// Arrange
const currentPathname = '/';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2F');
});
});
describe('with locale', () => {
it('should build login path with locale and append returnTo', () => {
// Arrange
const currentPathname = '/de/dashboard';
mockInterpreter.interpret.mockReturnValue({
locale: 'de',
logicalPathname: '/dashboard',
});
mockPathBuilder.build.mockReturnValue('/de/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/dashboard');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/de/dashboard');
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'auth.login',
{},
{ locale: 'de' }
);
expect(mockSanitizer.sanitizeReturnTo).toHaveBeenCalledWith(
'/de/dashboard',
'/'
);
expect(result).toBe('/de/auth/login?returnTo=%2Fdashboard');
});
it('should handle different locales', () => {
// Arrange
const currentPathname = '/fr/races/123';
mockInterpreter.interpret.mockReturnValue({
locale: 'fr',
logicalPathname: '/races/123',
});
mockPathBuilder.build.mockReturnValue('/fr/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/races/123');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/fr/auth/login?returnTo=%2Fraces%2F123');
});
});
describe('with invalid returnTo', () => {
it('should use fallback when sanitizer returns fallback', () => {
// Arrange
const currentPathname = '/api/something';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/api/something',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(mockSanitizer.sanitizeReturnTo).toHaveBeenCalledWith(
'/api/something',
'/'
);
expect(result).toBe('/auth/login?returnTo=%2F');
});
it('should handle malicious URLs', () => {
// Arrange
const currentPathname = 'https://evil.com/phishing';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: 'https://evil.com/phishing',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2F');
});
});
describe('edge cases', () => {
it('should handle empty currentPathname', () => {
// Arrange
const currentPathname = '';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2F');
});
it('should handle paths with query strings', () => {
// Arrange
const currentPathname = '/dashboard?tab=settings';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/dashboard?tab=settings',
});
mockPathBuilder.build.mockReturnValue('/auth/login');
mockSanitizer.sanitizeReturnTo.mockReturnValue('/dashboard?tab=settings');
// Act
const result = builder.toLogin({ currentPathname });
// Assert
expect(result).toBe('/auth/login?returnTo=%2Fdashboard%3Ftab%3Dsettings');
});
});
});
describe('awayFromAuthPage', () => {
describe('with driver role', () => {
it('should redirect to driver dashboard without locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-123',
email: 'driver@example.com',
displayName: 'Driver',
role: 'driver',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/auth/login');
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('driver');
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'dashboard',
{},
{ locale: null }
);
expect(result).toBe('/dashboard');
});
it('should redirect to driver dashboard with locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-123',
email: 'driver@example.com',
displayName: 'Driver',
role: 'driver',
},
};
const currentPathname = '/de/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: 'de',
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/de/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'dashboard',
{},
{ locale: 'de' }
);
expect(result).toBe('/de/dashboard');
});
});
describe('with sponsor role', () => {
it('should redirect to sponsor dashboard without locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-456',
email: 'sponsor@example.com',
displayName: 'Sponsor',
role: 'sponsor',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('sponsor.dashboard');
mockPathBuilder.build.mockReturnValue('/sponsor/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('sponsor');
expect(result).toBe('/sponsor/dashboard');
});
it('should redirect to sponsor dashboard with locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-456',
email: 'sponsor@example.com',
displayName: 'Sponsor',
role: 'sponsor',
},
};
const currentPathname = '/fr/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: 'fr',
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('sponsor.dashboard');
mockPathBuilder.build.mockReturnValue('/fr/sponsor/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'sponsor.dashboard',
{},
{ locale: 'fr' }
);
expect(result).toBe('/fr/sponsor/dashboard');
});
});
describe('with admin role', () => {
it('should redirect to admin dashboard without locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-789',
email: 'admin@example.com',
displayName: 'Admin',
role: 'admin',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('admin');
mockPathBuilder.build.mockReturnValue('/admin');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('admin');
expect(result).toBe('/admin');
});
it('should redirect to admin dashboard with locale', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-789',
email: 'admin@example.com',
displayName: 'Admin',
role: 'admin',
},
};
const currentPathname = '/es/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: 'es',
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('admin');
mockPathBuilder.build.mockReturnValue('/es/admin');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPathBuilder.build).toHaveBeenCalledWith(
'admin',
{},
{ locale: 'es' }
);
expect(result).toBe('/es/admin');
});
});
describe('with owner role', () => {
it('should redirect to admin dashboard (owner maps to admin)', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-owner',
email: 'owner@example.com',
displayName: 'Owner',
role: 'owner',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('admin');
mockPathBuilder.build.mockReturnValue('/admin');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('owner');
expect(result).toBe('/admin');
});
});
describe('edge cases', () => {
it('should handle missing role (defaults to /dashboard)', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-no-role',
email: 'norole@example.com',
displayName: 'NoRole',
// role is undefined
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('');
expect(result).toBe('/dashboard');
});
it('should handle empty role string', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-empty-role',
email: 'emptyrole@example.com',
displayName: 'EmptyRole',
role: '',
},
};
const currentPathname = '/auth/login';
mockInterpreter.interpret.mockReturnValue({
locale: null,
logicalPathname: '/auth/login',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockPolicy.roleHomeRouteId).toHaveBeenCalledWith('');
expect(result).toBe('/dashboard');
});
it('should handle paths with locale and complex paths', () => {
// Arrange
const session: AuthSessionDTO = {
token: 'test-token',
user: {
userId: 'user-123',
email: 'driver@example.com',
displayName: 'Driver',
role: 'driver',
},
};
const currentPathname = '/de/leagues/123/roster/admin';
mockInterpreter.interpret.mockReturnValue({
locale: 'de',
logicalPathname: '/leagues/123/roster/admin',
});
mockPolicy.roleHomeRouteId.mockReturnValue('dashboard');
mockPathBuilder.build.mockReturnValue('/de/dashboard');
// Act
const result = builder.awayFromAuthPage({ session, currentPathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith('/de/leagues/123/roster/admin');
expect(result).toBe('/de/dashboard');
});
});
});
});

View File

@@ -0,0 +1,82 @@
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { ReturnToSanitizer } from './ReturnToSanitizer';
import { RoutePathBuilder } from './RoutePathBuilder';
import { PathnameInterpreter } from './PathnameInterpreter';
/**
* AuthRedirectBuilder - Builds redirect URLs for authentication flows
*
* Responsibilities:
* - Build login redirect with sanitized returnTo parameter
* - Build redirect away from auth pages based on user role
* - Preserve locale from current path
*
* Pure-ish (no server dependencies)
*/
export class AuthRedirectBuilder {
constructor(
private policy: RouteAccessPolicy,
private sanitizer: ReturnToSanitizer,
private pathBuilder: RoutePathBuilder,
private interpreter: PathnameInterpreter
) {}
/**
* Build redirect URL to login page with returnTo parameter
*
* @param currentPathname - The current URL pathname (can include locale)
* @returns Redirect URL to login page with sanitized returnTo
*
* Example:
* - '/dashboard' → '/auth/login?returnTo=%2Fdashboard'
* - '/de/dashboard' → '/de/auth/login?returnTo=%2Fdashboard'
* - '/api/evil' → '/auth/login?returnTo=%2F' (sanitized)
*/
toLogin({ currentPathname }: { currentPathname: string }): string {
// Interpret current path to extract locale
const { locale } = this.interpreter.interpret(currentPathname);
// Build login path with locale
const loginPath = this.pathBuilder.build('auth.login', {}, { locale });
// Sanitize returnTo (use current path as input, fallback to root)
const sanitizedReturnTo = this.sanitizer.sanitizeReturnTo(currentPathname, '/');
// Append returnTo as query parameter
const returnToParam = encodeURIComponent(sanitizedReturnTo);
return `${loginPath}?returnTo=${returnToParam}`;
}
/**
* Build redirect URL away from auth page based on user role
*
* @param session - Current authentication session
* @param currentPathname - The current URL pathname (can include locale)
* @returns Redirect URL to role-appropriate home page
*
* Example:
* - driver role, '/auth/login' → '/dashboard'
* - sponsor role, '/de/auth/login' → '/de/sponsor/dashboard'
* - admin role, '/auth/login' → '/admin'
* - no role, '/auth/login' → '/dashboard' (default)
*/
awayFromAuthPage({
session,
currentPathname,
}: {
session: AuthSessionDTO;
currentPathname: string;
}): string {
// Extract locale from current path
const { locale } = this.interpreter.interpret(currentPathname);
// Get role-appropriate route ID
const role = session.user?.role;
const routeId = this.policy.roleHomeRouteId(role ?? '');
// Build path with locale
return this.pathBuilder.build(routeId, {}, { locale });
}
}

View File

@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest';
import { PathnameInterpreter } from './PathnameInterpreter';
describe('PathnameInterpreter', () => {
describe('interpret() - no locale prefix cases', () => {
it('should handle root path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/');
expect(result).toEqual({
locale: null,
logicalPathname: '/',
});
});
it('should handle simple path without locale', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/dashboard',
});
});
it('should handle dynamic route without locale', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/leagues/123');
expect(result).toEqual({
locale: null,
logicalPathname: '/leagues/123',
});
});
it('should handle nested path without locale', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/auth/login');
expect(result).toEqual({
locale: null,
logicalPathname: '/auth/login',
});
});
});
describe('interpret() - with locale prefix', () => {
it('should strip valid 2-letter locale prefix', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/de/dashboard');
expect(result).toEqual({
locale: 'de',
logicalPathname: '/dashboard',
});
});
it('should handle locale prefix with dynamic route', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/en/leagues/456');
expect(result).toEqual({
locale: 'en',
logicalPathname: '/leagues/456',
});
});
it('should handle locale prefix with root path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/fr/');
expect(result).toEqual({
locale: 'fr',
logicalPathname: '/',
});
});
it('should handle locale prefix with nested path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/es/auth/settings');
expect(result).toEqual({
locale: 'es',
logicalPathname: '/auth/settings',
});
});
});
describe('interpret() - edge cases', () => {
it('should not strip invalid locale (numeric)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/999/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/999/dashboard',
});
});
it('should not strip invalid locale (3 letters)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/eng/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/eng/dashboard',
});
});
it('should not strip invalid locale (uppercase)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/DE/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/DE/dashboard',
});
});
it('should not strip invalid locale (with special chars)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/d-/dashboard');
expect(result).toEqual({
locale: null,
logicalPathname: '/d-/dashboard',
});
});
it('should handle empty path', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('');
expect(result).toEqual({
locale: null,
logicalPathname: '',
});
});
it('should handle path with only locale (no trailing slash)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/de');
expect(result).toEqual({
locale: 'de',
logicalPathname: '/',
});
});
it('should handle path with only locale (with trailing slash)', () => {
const interpreter = new PathnameInterpreter();
const result = interpreter.interpret('/de/');
expect(result).toEqual({
locale: 'de',
logicalPathname: '/',
});
});
});
});

View File

@@ -0,0 +1,97 @@
/**
* PathnameInterpreter
*
* Server-only utility for interpreting URL pathnames and extracting locale information.
* Strips locale prefix if present and returns the logical pathname.
*
* Examples:
* - '/de/dashboard' → { locale: 'de', logicalPathname: '/dashboard' }
* - '/dashboard' → { locale: null, logicalPathname: '/dashboard' }
* - '/' → { locale: null, logicalPathname: '/' }
* - '/999/dashboard' → { locale: null, logicalPathname: '/999/dashboard' }
*/
export interface PathnameInterpretation {
locale: string | null;
logicalPathname: string;
}
export class PathnameInterpreter {
/**
* Interprets a pathname and extracts locale information
*
* @param pathname - The URL pathname to interpret
* @returns Object with locale (if valid 2-letter code) and logical pathname
*/
interpret(pathname: string): PathnameInterpretation {
// Handle empty path
if (pathname === '') {
return {
locale: null,
logicalPathname: '',
};
}
// Handle root path
if (pathname === '/') {
return {
locale: null,
logicalPathname: '/',
};
}
// Normalize pathname (remove trailing slash for consistent processing)
const normalizedPathname = pathname.endsWith('/') && pathname.length > 1
? pathname.slice(0, -1)
: pathname;
// Split into segments
const segments = normalizedPathname.split('/').filter(Boolean);
// No segments to process
if (segments.length === 0) {
return {
locale: null,
logicalPathname: '/',
};
}
// Check if first segment is a valid 2-letter locale code
const firstSegment = segments[0];
if (this.isValidLocale(firstSegment)) {
// Valid locale detected - strip it
const remainingSegments = segments.slice(1);
const logicalPathname = remainingSegments.length > 0
? '/' + remainingSegments.join('/')
: '/';
return {
locale: firstSegment,
logicalPathname,
};
}
// No valid locale prefix found
return {
locale: null,
logicalPathname: pathname,
};
}
/**
* Validates if a string is a valid 2-letter locale code
* Must be exactly 2 lowercase letters (a-z)
*
* @param segment - The segment to validate
* @returns True if valid locale code
*/
private isValidLocale(segment: string): boolean {
// Must be exactly 2 characters
if (segment.length !== 2) {
return false;
}
// Must be lowercase letters only (a-z)
return /^[a-z]{2}$/.test(segment);
}
}

View File

@@ -0,0 +1,168 @@
import { ReturnToSanitizer } from './ReturnToSanitizer';
describe('ReturnToSanitizer', () => {
let sanitizer: ReturnToSanitizer;
beforeEach(() => {
sanitizer = new ReturnToSanitizer();
});
describe('sanitizeReturnTo', () => {
const FALLBACK = '/dashboard';
it('should return fallback when input is null', () => {
const result = sanitizer.sanitizeReturnTo(null, FALLBACK);
expect(result).toBe(FALLBACK);
});
it('should return fallback when input is empty string', () => {
const result = sanitizer.sanitizeReturnTo('', FALLBACK);
expect(result).toBe(FALLBACK);
});
it('should return fallback when input is undefined', () => {
const result = sanitizer.sanitizeReturnTo(undefined as any, FALLBACK);
expect(result).toBe(FALLBACK);
});
it('should accept valid relative paths starting with /', () => {
const validPaths = [
'/dashboard',
'/profile/settings',
'/leagues/123',
'/sponsor/dashboard',
'/admin/users',
'/',
];
validPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(path);
});
});
it('should strip protocol and host from absolute URLs', () => {
const testCases = [
{ input: 'https://example.com/dashboard', expected: '/dashboard' },
{ input: 'http://example.com/profile', expected: '/profile' },
{ input: 'https://evil.com/steal', expected: '/steal' },
];
testCases.forEach(({ input, expected }) => {
const result = sanitizer.sanitizeReturnTo(input, FALLBACK);
expect(result).toBe(expected);
});
});
it('should reject paths starting with /api/', () => {
const apiPaths = [
'/api/users',
'/api/auth/login',
'/api/internal/endpoint',
];
apiPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should reject paths starting with /_next/', () => {
const nextPaths = [
'/_next/static',
'/_next/data',
'/_next/image',
];
nextPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should reject paths with file extensions', () => {
const filePaths = [
'/document.pdf',
'/image.jpg',
'/script.js',
'/style.css',
'/data.json',
'/path/to/file.txt',
];
filePaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should reject relative paths without leading /', () => {
const relativePaths = [
'dashboard',
'profile/settings',
'../evil',
'./local',
];
relativePaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should handle complex valid paths', () => {
const complexPaths = [
'/leagues/abc-123/schedule',
'/races/456/results',
'/profile/liveries/upload',
'/sponsor/leagues/def-456',
];
complexPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(path);
});
});
it('should handle absolute URLs with query parameters', () => {
const result = sanitizer.sanitizeReturnTo(
'https://example.com/dashboard?tab=settings',
FALLBACK
);
expect(result).toBe('/dashboard?tab=settings');
});
it('should handle relative paths with query parameters', () => {
const result = sanitizer.sanitizeReturnTo(
'/profile?section=security',
FALLBACK
);
expect(result).toBe('/profile?section=security');
});
it('should reject paths with multiple dots (potential file extensions)', () => {
const paths = [
'/path/file.tar.gz',
'/api/v1/data.xml',
'/download/file.backup',
];
paths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(FALLBACK);
});
});
it('should accept paths with dots that are not extensions', () => {
const validPaths = [
'/leagues/v1.0/dashboard', // version in path
'/user/john.doe', // username with dot
];
validPaths.forEach(path => {
const result = sanitizer.sanitizeReturnTo(path, FALLBACK);
expect(result).toBe(path);
});
});
});
});

View File

@@ -0,0 +1,69 @@
/**
* ReturnToSanitizer - Sanitizes returnTo URLs to prevent open redirects
*
* Security Rules:
* - Must start with '/'
* - Strip protocol and host from absolute URLs
* - Block /api/* routes
* - Block /_next/* routes (Next.js internals)
* - Block paths with file extensions
* - Return fallback for invalid inputs
*/
export class ReturnToSanitizer {
/**
* Sanitizes a returnTo URL to ensure it's safe for redirection
*
* @param input - The raw returnTo value (can be null, undefined, or string)
* @param fallbackPathname - Fallback path if input is invalid
* @returns Sanitized path safe for redirection
*/
sanitizeReturnTo(
input: string | null | undefined,
fallbackPathname: string
): string {
// Handle null/undefined/empty
if (!input || input.trim() === '') {
return fallbackPathname;
}
let path = input.trim();
// Strip protocol and host from absolute URLs
// Matches: https://example.com/path, http://localhost:3000/path
if (path.match(/^https?:\/\//)) {
try {
const url = new URL(path);
path = url.pathname + url.search;
} catch {
// Invalid URL format
return fallbackPathname;
}
}
// Must start with /
if (!path.startsWith('/')) {
return fallbackPathname;
}
// Block API routes
if (path.startsWith('/api/')) {
return fallbackPathname;
}
// Block Next.js internal routes
if (path.startsWith('/_next/')) {
return fallbackPathname;
}
// Block paths with file extensions
// Check for common file extensions at the end or before query string
// Excludes version numbers (v1.0) and usernames (john.doe) but catches .pdf, .jpg, .tar.gz, .backup, etc.
const fileExtensionPattern = /\.(pdf|jpg|jpeg|png|gif|webp|ico|css|js|json|xml|txt|csv|tar|gz|zip|mp4|webm|mov|avi|mp3|wav|svg|bmp|tiff|woff|woff2|ttf|eot|backup|bak|sql|db|exe|dmg|iso|rar|7z)($|\?)/i;
if (fileExtensionPattern.test(path)) {
return fallbackPathname;
}
// Valid path
return path;
}
}

View File

@@ -0,0 +1,256 @@
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { RouteCatalog } from './RouteCatalog';
describe('RouteAccessPolicy', () => {
let policy: RouteAccessPolicy;
let catalog: RouteCatalog;
beforeEach(() => {
catalog = new RouteCatalog();
policy = new RouteAccessPolicy(catalog);
});
describe('isPublic', () => {
it('should return true for public routes', () => {
const publicRoutes = [
'/',
'/leagues',
'/drivers',
'/teams',
'/leaderboards',
'/races',
'/sponsor/signup',
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
'/404',
'/500',
];
publicRoutes.forEach(route => {
expect(policy.isPublic(route)).toBe(true);
});
});
it('should return false for protected routes', () => {
const protectedRoutes = [
'/dashboard',
'/onboarding',
'/profile',
'/profile/settings',
'/sponsor/dashboard',
'/sponsor/billing',
'/admin/users',
'/leagues/create',
];
protectedRoutes.forEach(route => {
expect(policy.isPublic(route)).toBe(false);
});
});
it('should handle wildcard patterns', () => {
// These should match patterns from RouteCatalog
expect(policy.isPublic('/leagues/123')).toBe(true);
expect(policy.isPublic('/drivers/456')).toBe(true);
expect(policy.isPublic('/teams/789')).toBe(true);
expect(policy.isPublic('/races/123')).toBe(true);
expect(policy.isPublic('/races/all')).toBe(true);
});
});
describe('isAuthPage', () => {
it('should return true for auth pages', () => {
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
];
authRoutes.forEach(route => {
expect(policy.isAuthPage(route)).toBe(true);
});
});
it('should return false for non-auth pages', () => {
const nonAuthRoutes = [
'/',
'/dashboard',
'/leagues',
'/sponsor/dashboard',
'/admin/users',
];
nonAuthRoutes.forEach(route => {
expect(policy.isAuthPage(route)).toBe(false);
});
});
});
describe('requiredRoles', () => {
it('should return null for public routes', () => {
const publicRoutes = [
'/',
'/leagues',
'/drivers',
'/auth/login',
];
publicRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toBeNull();
});
});
it('should return null for auth-only routes (no specific role)', () => {
const authRoutes = [
'/dashboard',
'/onboarding',
'/profile',
'/profile/settings',
'/profile/leagues',
];
authRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toBeNull();
});
});
it('should return sponsor role for sponsor routes', () => {
const sponsorRoutes = [
'/sponsor',
'/sponsor/dashboard',
'/sponsor/billing',
'/sponsor/campaigns',
'/sponsor/leagues',
'/sponsor/settings',
];
sponsorRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toEqual(['sponsor']);
});
});
it('should return admin roles for admin routes', () => {
const adminRoutes = [
'/admin',
'/admin/users',
'/leagues/123/schedule/admin',
'/leagues/123/roster/admin',
'/leagues/123/stewarding',
'/leagues/123/wallet',
];
adminRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toEqual(['system-owner', 'super-admin', 'league-admin']);
});
});
it('should return steward roles for race stewarding routes', () => {
const stewardRoutes = [
'/races/456/stewarding',
];
stewardRoutes.forEach(route => {
expect(policy.requiredRoles(route)).toEqual(['system-owner', 'super-admin', 'league-steward']);
});
});
it('should handle league-specific admin routes', () => {
const result = policy.requiredRoles('/leagues/abc-123/settings');
expect(result).toEqual(['system-owner', 'super-admin', 'league-admin']);
});
it('should handle race-specific stewarding routes', () => {
const result = policy.requiredRoles('/races/xyz-789/stewarding');
expect(result).toEqual(['system-owner', 'super-admin', 'league-steward']);
});
});
describe('roleHome', () => {
it('should return correct home path for driver role', () => {
const result = policy.roleHome('driver');
expect(result).toBe('/dashboard');
});
it('should return correct home path for sponsor role', () => {
const result = policy.roleHome('sponsor');
expect(result).toBe('/sponsor/dashboard');
});
it('should return correct home path for league-admin role', () => {
const result = policy.roleHome('league-admin');
expect(result).toBe('/admin');
});
it('should return correct home path for league-steward role', () => {
const result = policy.roleHome('league-steward');
expect(result).toBe('/admin');
});
it('should return correct home path for league-owner role', () => {
const result = policy.roleHome('league-owner');
expect(result).toBe('/admin');
});
it('should return correct home path for system-owner role', () => {
const result = policy.roleHome('system-owner');
expect(result).toBe('/admin');
});
it('should return correct home path for super-admin role', () => {
const result = policy.roleHome('super-admin');
expect(result).toBe('/admin');
});
it('should handle unknown roles gracefully', () => {
const result = policy.roleHome('unknown');
// Should return a sensible default (dashboard)
expect(result).toBe('/dashboard');
});
});
describe('roleHomeRouteId', () => {
it('should return correct route ID for driver role', () => {
const result = policy.roleHomeRouteId('driver');
expect(result).toBe('dashboard');
});
it('should return correct route ID for sponsor role', () => {
const result = policy.roleHomeRouteId('sponsor');
expect(result).toBe('sponsor.dashboard');
});
it('should return correct route ID for admin roles', () => {
const adminRoles = ['league-admin', 'league-steward', 'league-owner', 'system-owner', 'super-admin'];
adminRoles.forEach(role => {
const result = policy.roleHomeRouteId(role);
expect(result).toBe('admin');
});
});
});
describe('integration scenarios', () => {
it('should correctly classify common user journey paths', () => {
// Public user browsing
expect(policy.isPublic('/leagues')).toBe(true);
expect(policy.requiredRoles('/leagues')).toBeNull();
// Authenticated user
expect(policy.isPublic('/dashboard')).toBe(false);
expect(policy.requiredRoles('/dashboard')).toBeNull();
// Sponsor user
expect(policy.isPublic('/sponsor/dashboard')).toBe(false);
expect(policy.requiredRoles('/sponsor/dashboard')).toEqual(['sponsor']);
expect(policy.roleHome('sponsor')).toBe('/sponsor/dashboard');
// Admin user
expect(policy.isPublic('/admin/users')).toBe(false);
expect(policy.requiredRoles('/admin/users')).toEqual(['system-owner', 'super-admin', 'league-admin']);
expect(policy.roleHome('league-admin')).toBe('/admin');
});
});
});

View File

@@ -0,0 +1,72 @@
import { RouteCatalog } from './RouteCatalog';
/**
* RouteAccessPolicy - Determines access requirements for routes
*
* Responsibilities:
* - Check if a route is public
* - Check if a route is an auth page
* - Determine required roles for a route
* - Get home path for a specific role
*
* Design: Uses ONLY RouteCatalog patterns/matchers, no hardcoded arrays/strings
*/
export class RouteAccessPolicy {
constructor(private catalog: RouteCatalog) {}
/**
* Check if a logical pathname is publicly accessible
* @param logicalPathname - The path to check
* @returns true if the route is public (no auth required)
*/
isPublic(logicalPathname: string): boolean {
// Get the route ID for this path
const routeId = this.catalog.getRouteIdByPath(logicalPathname);
if (!routeId) {
// No route found, not public
return false;
}
// Check if this route ID is in the public routes list
const publicRouteIds = this.catalog.listPublicRoutes();
return publicRouteIds.includes(routeId);
}
/**
* Check if a logical pathname is an auth page
* @param logicalPathname - The path to check
* @returns true if the route is an auth page
*/
isAuthPage(logicalPathname: string): boolean {
return this.catalog.isAuthPage(logicalPathname);
}
/**
* Get required roles for a logical pathname
* @param logicalPathname - The path to check
* @returns Array of required roles, or null if no specific role required
*/
requiredRoles(logicalPathname: string): string[] | null {
// Use catalog's role-based access method
return this.catalog.getRequiredRoles(logicalPathname);
}
/**
* Get the home path for a specific role
* @param role - The role name
* @returns The logical path for that role's home page
*/
roleHome(role: string): string {
return this.catalog.getRoleHome(role);
}
/**
* Get the route ID for a specific role's home page
* @param role - The role name
* @returns The route ID for that role's home page
*/
roleHomeRouteId(role: string): string {
return this.catalog.getRoleHomeRouteId(role);
}
}

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { RouteCatalog } from './RouteCatalog';
describe('RouteCatalog', () => {
describe('constructor', () => {
it('should create an instance without errors', () => {
const catalog = new RouteCatalog();
expect(catalog).toBeInstanceOf(RouteCatalog);
});
});
describe('listPublicRoutes()', () => {
it('should return array of public route IDs', () => {
const catalog = new RouteCatalog();
const publicRoutes = catalog.listPublicRoutes();
expect(Array.isArray(publicRoutes)).toBe(true);
expect(publicRoutes.length).toBeGreaterThan(0);
expect(publicRoutes).toContain('auth.login');
expect(publicRoutes).toContain('public.home');
expect(publicRoutes).toContain('error.notFound');
});
});
describe('listProtectedRoutes()', () => {
it('should return array of protected route IDs', () => {
const catalog = new RouteCatalog();
const protectedRoutes = catalog.listProtectedRoutes();
expect(Array.isArray(protectedRoutes)).toBe(true);
expect(protectedRoutes.length).toBeGreaterThan(0);
expect(protectedRoutes).toContain('protected.dashboard');
expect(protectedRoutes).toContain('protected.profile');
expect(protectedRoutes).toContain('sponsor.dashboard');
});
});
describe('getPattern()', () => {
it('should return pattern for simple route ID', () => {
const catalog = new RouteCatalog();
const pattern = catalog.getPattern('auth.login');
expect(pattern).toBe('/auth/login');
});
it('should return pattern for protected route', () => {
const catalog = new RouteCatalog();
const pattern = catalog.getPattern('protected.dashboard');
expect(pattern).toBe('/dashboard');
});
it('should return pattern for public route', () => {
const catalog = new RouteCatalog();
const pattern = catalog.getPattern('public.leagues');
expect(pattern).toBe('/leagues');
});
it('should throw error for unknown route ID', () => {
const catalog = new RouteCatalog();
expect(() => catalog.getPattern('unknown.route')).toThrow('Unknown route ID: unknown.route');
});
});
describe('isAuthPage()', () => {
it('should return true for auth pages', () => {
const catalog = new RouteCatalog();
expect(catalog.isAuthPage('/auth/login')).toBe(true);
expect(catalog.isAuthPage('/auth/signup')).toBe(true);
expect(catalog.isAuthPage('/auth/forgot-password')).toBe(true);
});
it('should return false for non-auth pages', () => {
const catalog = new RouteCatalog();
expect(catalog.isAuthPage('/dashboard')).toBe(false);
expect(catalog.isAuthPage('/leagues')).toBe(false);
expect(catalog.isAuthPage('/')).toBe(false);
});
});
describe('getAllPatterns()', () => {
it('should return all route patterns', () => {
const catalog = new RouteCatalog();
const patterns = catalog.getAllPatterns();
expect(Array.isArray(patterns)).toBe(true);
expect(patterns.length).toBeGreaterThan(0);
expect(patterns.some(p => p.routeId === 'auth.login')).toBe(true);
expect(patterns.some(p => p.routeId === 'protected.dashboard')).toBe(true);
});
});
describe('getRouteIdByPath()', () => {
it('should return route ID for exact match', () => {
const catalog = new RouteCatalog();
const routeId = catalog.getRouteIdByPath('/auth/login');
expect(routeId).toBe('auth.login');
});
it('should return route ID for protected path', () => {
const catalog = new RouteCatalog();
const routeId = catalog.getRouteIdByPath('/dashboard');
expect(routeId).toBe('protected.dashboard');
});
it('should return null for unknown path', () => {
const catalog = new RouteCatalog();
const routeId = catalog.getRouteIdByPath('/unknown/path');
expect(routeId).toBeNull();
});
});
});

View File

@@ -0,0 +1,274 @@
import { routes, routeMatchers } from '../routing/RouteConfig';
/**
* RouteCatalog exposes route IDs and patterns for matching
*
* Route IDs follow the pattern: 'category.routeName'
* Examples:
* - 'auth.login' → '/auth/login'
* - 'protected.dashboard' → '/dashboard'
* - 'league.detail' → '/leagues/[id]' (pattern)
*/
export class RouteCatalog {
/**
* List all public route IDs
* Public routes are accessible without authentication
*/
listPublicRoutes(): string[] {
return [
'public.home',
'public.leagues',
'public.drivers',
'public.teams',
'public.leaderboards',
'public.races',
'public.sponsorSignup',
'auth.login',
'auth.signup',
'auth.forgotPassword',
'auth.resetPassword',
'auth.iRacingStart',
'auth.iRacingCallback',
'error.notFound',
'error.serverError',
// Parameterized public routes
'league.detail',
'league.rulebook',
'league.schedule',
'league.standings',
'driver.detail',
'team.detail',
'race.detail',
'race.results',
'race.all',
];
}
/**
* List all protected route IDs
* Protected routes require authentication
*/
listProtectedRoutes(): string[] {
return [
'protected.dashboard',
'protected.onboarding',
'protected.profile',
'protected.profileSettings',
'protected.profileLeagues',
'protected.profileLiveries',
'protected.profileLiveryUpload',
'protected.profileSponsorshipRequests',
'sponsor.root',
'sponsor.dashboard',
'sponsor.billing',
'sponsor.campaigns',
'sponsor.leagues',
'sponsor.settings',
'admin.root',
'admin.users',
'league.create',
'race.root',
'team.root',
'team.leaderboard',
];
}
/**
* List all admin route IDs
* Admin routes require admin-level permissions
*/
listAdminRoutes(): string[] {
return [
'admin.root',
'admin.users',
'league.rosterAdmin',
'league.scheduleAdmin',
'league.stewarding',
'league.settings',
'league.sponsorships',
'league.wallet',
'race.stewarding',
];
}
/**
* List all sponsor route IDs
* Sponsor routes require sponsor role
*/
listSponsorRoutes(): string[] {
return [
'sponsor.root',
'sponsor.dashboard',
'sponsor.billing',
'sponsor.campaigns',
'sponsor.leagues',
'sponsor.settings',
];
}
/**
* Get the path pattern for a route ID
* @param routeId - Route ID in format 'category.routeName'
* @returns Path pattern (e.g., '/auth/login' or '/leagues/[id]')
* @throws Error if route ID is unknown
*/
getPattern(routeId: string): string {
const parts = routeId.split('.');
let route: any = routes;
for (const part of parts) {
route = route[part];
if (!route) {
throw new Error(`Unknown route ID: ${routeId}`);
}
}
// Handle parameterized routes
if (typeof route === 'function') {
// Return pattern with placeholder
const paramPattern = route('placeholder');
return paramPattern.replace('/placeholder', '/[id]');
}
return route as string;
}
/**
* Check if a path is an auth page
* @param logicalPath - Path to check
* @returns True if path is an auth page
*/
isAuthPage(logicalPath: string): boolean {
return routeMatchers.isInGroup(logicalPath, 'auth');
}
/**
* Get all route patterns with their IDs
* @returns Array of route patterns with IDs
*/
getAllPatterns(): Array<{ routeId: string; pattern: string }> {
const patterns: Array<{ routeId: string; pattern: string }> = [];
// Helper to traverse routes and build patterns
const traverse = (obj: any, prefix: string) => {
for (const [key, value] of Object.entries(obj)) {
const routeId = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'function') {
// Parameterized route
const pattern = value('placeholder').replace('/placeholder', '/[id]');
patterns.push({ routeId, pattern });
} else if (typeof value === 'object' && value !== null) {
// Nested category
traverse(value, routeId);
} else if (typeof value === 'string') {
// Simple route
patterns.push({ routeId, pattern: value });
}
}
};
traverse(routes, '');
return patterns;
}
/**
* Get route ID by path
* @param path - Path to find
* @returns Route ID or null if not found
*
* Note: This method prioritizes exact matches over parameterized matches.
* For example, '/leagues/create' will match 'league.create' before 'league.detail'.
*/
getRouteIdByPath(path: string): string | null {
const allPatterns = this.getAllPatterns();
// First, try exact matches
for (const { routeId, pattern } of allPatterns) {
if (pattern === path) {
return routeId;
}
}
// Then, try parameterized matches
for (const { routeId, pattern } of allPatterns) {
if (pattern.includes('[')) {
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
if (regex.test(path)) {
return routeId;
}
}
}
return null;
}
/**
* Check if a path requires specific role-based access
* @param logicalPath - Path to check
* @returns Array of required roles or null
*/
getRequiredRoles(logicalPath: string): string[] | null {
// Check admin routes
if (routeMatchers.isInGroup(logicalPath, 'admin')) {
return ['system-owner', 'super-admin', 'league-admin'];
}
// Check sponsor routes
if (routeMatchers.isInGroup(logicalPath, 'sponsor')) {
return ['sponsor'];
}
// Check league admin routes (specific patterns)
if (logicalPath.match(/\/leagues\/[^/]+\/(roster\/admin|schedule\/admin|stewarding|settings|sponsorships|wallet)/)) {
return ['system-owner', 'super-admin', 'league-admin'];
}
// Check race stewarding routes
if (logicalPath.match(/\/races\/[^/]+\/stewarding/)) {
return ['system-owner', 'super-admin', 'league-steward'];
}
// Public or auth-only routes (no specific role)
return null;
}
/**
* Get the home path for a specific role
* @param role - The role name
* @returns The logical path for that role's home page
*/
getRoleHome(role: string): string {
const roleHomeMap: Record<string, string> = {
'driver': '/dashboard',
'sponsor': '/sponsor/dashboard',
'league-admin': '/admin',
'league-steward': '/admin',
'league-owner': '/admin',
'system-owner': '/admin',
'super-admin': '/admin',
};
return roleHomeMap[role] || '/dashboard';
}
/**
* Get the route ID for a specific role's home page
* @param role - The role name
* @returns The route ID for that role's home page
*/
getRoleHomeRouteId(role: string): string {
const roleHomeRouteMap: Record<string, string> = {
'driver': 'protected.dashboard',
'sponsor': 'sponsor.dashboard',
'league-admin': 'admin',
'league-steward': 'admin',
'league-owner': 'admin',
'system-owner': 'admin',
'super-admin': 'admin',
};
return roleHomeRouteMap[role] || 'protected.dashboard';
}
}

View File

@@ -0,0 +1,223 @@
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
import { RouteGuard } from './RouteGuard';
import { PathnameInterpreter } from './PathnameInterpreter';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { SessionGateway } from '../gateways/SessionGateway';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
// Hoist the mock redirect function
const mockRedirect = vi.hoisted(() => vi.fn());
// Mock next/navigation
vi.mock('next/navigation', () => ({
redirect: mockRedirect,
}));
// Mock dependencies
vi.mock('./PathnameInterpreter');
vi.mock('./RouteAccessPolicy');
vi.mock('../gateways/SessionGateway');
vi.mock('./AuthRedirectBuilder');
describe('RouteGuard', () => {
let routeGuard: RouteGuard;
let mockInterpreter: Mocked<PathnameInterpreter>;
let mockPolicy: Mocked<RouteAccessPolicy>;
let mockGateway: Mocked<SessionGateway>;
let mockBuilder: Mocked<AuthRedirectBuilder>;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create mock instances
mockInterpreter = {
interpret: vi.fn(),
} as any;
mockPolicy = {
isPublic: vi.fn(),
isAuthPage: vi.fn(),
requiredRoles: vi.fn(),
} as any;
mockGateway = {
getSession: vi.fn(),
} as any;
mockBuilder = {
awayFromAuthPage: vi.fn(),
toLogin: vi.fn(),
} as any;
// Create RouteGuard instance
routeGuard = new RouteGuard(
mockInterpreter,
mockPolicy,
mockGateway,
mockBuilder
);
});
describe('RED: public non-auth page → no redirect', () => {
it('should allow access without redirect for public non-auth pages', async () => {
// Arrange
const pathname = '/public/page';
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/public/page' });
mockPolicy.isPublic.mockReturnValue(true);
mockPolicy.isAuthPage.mockReturnValue(false);
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockInterpreter.interpret).toHaveBeenCalledWith(pathname);
expect(mockPolicy.isPublic).toHaveBeenCalledWith('/public/page');
expect(mockPolicy.isAuthPage).toHaveBeenCalledWith('/public/page');
expect(mockGateway.getSession).not.toHaveBeenCalled();
expect(mockRedirect).not.toHaveBeenCalled();
});
});
describe('auth page, no session → allow', () => {
it('should allow access to auth page when no session exists', async () => {
// Arrange
const pathname = '/login';
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/login' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(true);
mockGateway.getSession.mockResolvedValue(null);
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockRedirect).not.toHaveBeenCalled();
});
});
describe('auth page, session → away redirect', () => {
it('should redirect away from auth page when session exists', async () => {
// Arrange
const pathname = '/login';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/login' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(true);
mockGateway.getSession.mockResolvedValue(mockSession);
mockBuilder.awayFromAuthPage.mockReturnValue('/dashboard');
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockBuilder.awayFromAuthPage).toHaveBeenCalledWith({
session: mockSession,
currentPathname: '/login',
});
expect(mockRedirect).toHaveBeenCalledWith('/dashboard');
});
});
describe('protected, no session → login redirect', () => {
it('should redirect to login when accessing protected page without session', async () => {
// Arrange
const pathname = '/protected/dashboard';
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/protected/dashboard' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(null);
mockBuilder.toLogin.mockReturnValue('/login?redirect=/protected/dashboard');
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockBuilder.toLogin).toHaveBeenCalledWith({ currentPathname: '/protected/dashboard' });
expect(mockRedirect).toHaveBeenCalledWith('/login?redirect=/protected/dashboard');
});
});
describe('protected, wrong role → login', () => {
it('should redirect to login when user lacks required role', async () => {
// Arrange
const pathname = '/admin/panel';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/admin/panel' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(mockSession);
mockPolicy.requiredRoles.mockReturnValue(['admin']);
mockBuilder.toLogin.mockReturnValue('/login?redirect=/admin/panel');
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/admin/panel');
expect(mockBuilder.toLogin).toHaveBeenCalledWith({ currentPathname: '/admin/panel' });
expect(mockRedirect).toHaveBeenCalledWith('/login?redirect=/admin/panel');
});
});
describe('protected, correct role → allow', () => {
it('should allow access when user has required role', async () => {
// Arrange
const pathname = '/admin/panel';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'admin', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/admin/panel' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(mockSession);
mockPolicy.requiredRoles.mockReturnValue(['admin']);
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/admin/panel');
expect(mockRedirect).not.toHaveBeenCalled();
});
it('should allow access when no specific roles required', async () => {
// Arrange
const pathname = '/dashboard';
const mockSession: AuthSessionDTO = {
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
token: 'mock-token',
};
mockInterpreter.interpret.mockReturnValue({ locale: 'en', logicalPathname: '/dashboard' });
mockPolicy.isPublic.mockReturnValue(false);
mockPolicy.isAuthPage.mockReturnValue(false);
mockGateway.getSession.mockResolvedValue(mockSession);
mockPolicy.requiredRoles.mockReturnValue(null);
// Act
await routeGuard.enforce({ pathname });
// Assert
expect(mockGateway.getSession).toHaveBeenCalled();
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/dashboard');
expect(mockRedirect).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,56 @@
import { redirect } from 'next/navigation';
import { PathnameInterpreter } from './PathnameInterpreter';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { SessionGateway } from '../gateways/SessionGateway';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
export class RouteGuard {
constructor(
private readonly interpreter: PathnameInterpreter,
private readonly policy: RouteAccessPolicy,
private readonly gateway: SessionGateway,
private readonly builder: AuthRedirectBuilder
) {}
async enforce({ pathname }: { pathname: string }): Promise<void> {
// Step 1: Interpret the pathname
const { logicalPathname } = this.interpreter.interpret(pathname);
// Step 2: Check if public non-auth page
if (this.policy.isPublic(logicalPathname) && !this.policy.isAuthPage(logicalPathname)) {
return; // Allow access
}
// Step 3: Handle auth pages
if (this.policy.isAuthPage(logicalPathname)) {
const session = await this.gateway.getSession();
if (session) {
// User is logged in, redirect away from auth page
const redirectPath = this.builder.awayFromAuthPage({ session, currentPathname: pathname });
redirect(redirectPath);
}
// No session, allow access to auth page
return;
}
// Step 4: Handle protected pages
const session = await this.gateway.getSession();
// No session, redirect to login
if (!session) {
const loginPath = this.builder.toLogin({ currentPathname: pathname });
redirect(loginPath);
}
// Check required roles
const reqRoles = this.policy.requiredRoles(logicalPathname);
if (reqRoles && session.user?.role && !reqRoles.includes(session.user.role)) {
const loginPath = this.builder.toLogin({ currentPathname: pathname });
redirect(loginPath);
}
// All checks passed, allow access
return;
}
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import { RoutePathBuilder } from './RoutePathBuilder';
describe('RoutePathBuilder', () => {
describe('constructor', () => {
it('should create an instance without errors', () => {
const builder = new RoutePathBuilder();
expect(builder).toBeInstanceOf(RoutePathBuilder);
});
});
describe('build()', () => {
it('should build simple route paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('auth.login');
expect(path).toBe('/auth/login');
});
it('should build protected route paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('protected.dashboard');
expect(path).toBe('/dashboard');
});
it('should build parameterized route paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('league.detail', { id: '123' });
expect(path).toBe('/leagues/123');
});
it('should build sponsor league detail paths', () => {
const builder = new RoutePathBuilder();
const path = builder.build('sponsor.leagueDetail', { id: '456' });
expect(path).toBe('/sponsor/leagues/456');
});
it('should build paths with locale prefix', () => {
const builder = new RoutePathBuilder();
const path = builder.build('auth.login', {}, { locale: 'de' });
expect(path).toBe('/de/auth/login');
});
it('should build parameterized paths with locale', () => {
const builder = new RoutePathBuilder();
const path = builder.build('league.detail', { id: '123' }, { locale: 'de' });
expect(path).toBe('/de/leagues/123');
});
it('should build paths with different locales', () => {
const builder = new RoutePathBuilder();
const pathEn = builder.build('public.home', {}, { locale: 'en' });
const pathDe = builder.build('public.home', {}, { locale: 'de' });
expect(pathEn).toBe('/en/');
expect(pathDe).toBe('/de/');
});
it('should build paths without locale when not provided', () => {
const builder = new RoutePathBuilder();
const path = builder.build('public.leagues');
expect(path).toBe('/leagues');
});
it('should throw error for unknown route ID', () => {
const builder = new RoutePathBuilder();
expect(() => builder.build('unknown.route')).toThrow('Unknown route: unknown.route');
});
it('should throw error when parameterized route missing params', () => {
const builder = new RoutePathBuilder();
expect(() => builder.build('league.detail')).toThrow('Route league.detail requires parameters');
});
it('should throw error when parameterized route missing required param', () => {
const builder = new RoutePathBuilder();
expect(() => builder.build('league.detail', {})).toThrow('Route league.detail requires parameters');
});
it('should handle all route categories', () => {
const builder = new RoutePathBuilder();
// Auth routes
expect(builder.build('auth.login')).toBe('/auth/login');
expect(builder.build('auth.signup')).toBe('/auth/signup');
// Public routes
expect(builder.build('public.home')).toBe('/');
expect(builder.build('public.leagues')).toBe('/leagues');
// Protected routes
expect(builder.build('protected.dashboard')).toBe('/dashboard');
expect(builder.build('protected.profile')).toBe('/profile');
// Sponsor routes
expect(builder.build('sponsor.dashboard')).toBe('/sponsor/dashboard');
// Admin routes
expect(builder.build('admin.users')).toBe('/admin/users');
// League routes
expect(builder.build('league.detail', { id: '789' })).toBe('/leagues/789');
// Race routes
expect(builder.build('race.detail', { id: '999' })).toBe('/races/999');
});
it('should handle locale with all route types', () => {
const builder = new RoutePathBuilder();
expect(builder.build('auth.login', {}, { locale: 'fr' })).toBe('/fr/auth/login');
expect(builder.build('public.leagues', {}, { locale: 'fr' })).toBe('/fr/leagues');
expect(builder.build('protected.dashboard', {}, { locale: 'fr' })).toBe('/fr/dashboard');
expect(builder.build('league.detail', { id: '123' }, { locale: 'fr' })).toBe('/fr/leagues/123');
});
});
});

View File

@@ -0,0 +1,45 @@
import { buildPath } from '../routing/RouteConfig';
/**
* RoutePathBuilder builds paths from route IDs with optional parameters and locale
*
* Usage:
* ```typescript
* const builder = new RoutePathBuilder();
*
* // Simple route
* builder.build('auth.login'); // → '/auth/login'
*
* // With parameters
* builder.build('league.detail', { id: '123' }); // → '/leagues/123'
*
* // With locale
* builder.build('auth.login', {}, { locale: 'de' }); // → '/de/auth/login'
*
* // With parameters and locale
* builder.build('league.detail', { id: '123' }, { locale: 'de' }); // → '/de/leagues/123'
* ```
*/
export class RoutePathBuilder {
/**
* Build a path from route ID with optional parameters and locale
* @param routeId - Route ID in format 'category.routeName'
* @param params - Optional parameters for parameterized routes
* @param options - Optional options including locale
* @returns Complete path with optional locale prefix
*/
build(
routeId: string,
params?: Record<string, string>,
options?: { locale?: string | null }
): string {
const path = buildPath(routeId, params);
// Add locale prefix if provided
if (options?.locale) {
return `/${options.locale}${path}`;
}
return path;
}
}

View File

@@ -0,0 +1,31 @@
import { RouteGuard } from './RouteGuard';
import { PathnameInterpreter } from './PathnameInterpreter';
import { RouteCatalog } from './RouteCatalog';
import { RouteAccessPolicy } from './RouteAccessPolicy';
import { ReturnToSanitizer } from './ReturnToSanitizer';
import { RoutePathBuilder } from './RoutePathBuilder';
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
import { SessionGateway } from '../gateways/SessionGateway';
/**
* Factory function to create a RouteGuard instance with all dependencies
*
* Usage:
* ```typescript
* const guard = createRouteGuard();
* await guard.enforce({ pathname: '/dashboard' });
* ```
*
* @returns RouteGuard instance configured with all required dependencies
*/
export function createRouteGuard(): RouteGuard {
const catalog = new RouteCatalog();
const interpreter = new PathnameInterpreter();
const policy = new RouteAccessPolicy(catalog);
const sanitizer = new ReturnToSanitizer();
const pathBuilder = new RoutePathBuilder();
const redirectBuilder = new AuthRedirectBuilder(policy, sanitizer, pathBuilder, interpreter);
const gateway = new SessionGateway();
return new RouteGuard(interpreter, policy, gateway, redirectBuilder);
}