clean routes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
515
apps/website/lib/auth/AuthRedirectBuilder.test.ts
Normal file
515
apps/website/lib/auth/AuthRedirectBuilder.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/website/lib/auth/AuthRedirectBuilder.ts
Normal file
82
apps/website/lib/auth/AuthRedirectBuilder.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
160
apps/website/lib/auth/PathnameInterpreter.test.ts
Normal file
160
apps/website/lib/auth/PathnameInterpreter.test.ts
Normal 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: '/',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
97
apps/website/lib/auth/PathnameInterpreter.ts
Normal file
97
apps/website/lib/auth/PathnameInterpreter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
168
apps/website/lib/auth/ReturnToSanitizer.test.ts
Normal file
168
apps/website/lib/auth/ReturnToSanitizer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
69
apps/website/lib/auth/ReturnToSanitizer.ts
Normal file
69
apps/website/lib/auth/ReturnToSanitizer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
256
apps/website/lib/auth/RouteAccessPolicy.test.ts
Normal file
256
apps/website/lib/auth/RouteAccessPolicy.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
72
apps/website/lib/auth/RouteAccessPolicy.ts
Normal file
72
apps/website/lib/auth/RouteAccessPolicy.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
119
apps/website/lib/auth/RouteCatalog.test.ts
Normal file
119
apps/website/lib/auth/RouteCatalog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
274
apps/website/lib/auth/RouteCatalog.ts
Normal file
274
apps/website/lib/auth/RouteCatalog.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
223
apps/website/lib/auth/RouteGuard.test.ts
Normal file
223
apps/website/lib/auth/RouteGuard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
56
apps/website/lib/auth/RouteGuard.ts
Normal file
56
apps/website/lib/auth/RouteGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
apps/website/lib/auth/RoutePathBuilder.test.ts
Normal file
126
apps/website/lib/auth/RoutePathBuilder.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
45
apps/website/lib/auth/RoutePathBuilder.ts
Normal file
45
apps/website/lib/auth/RoutePathBuilder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
apps/website/lib/auth/createRouteGuard.ts
Normal file
31
apps/website/lib/auth/createRouteGuard.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user