middleware test

This commit is contained in:
2026-01-03 22:05:00 +01:00
parent c589b3c3fe
commit bc7cb2e20a
7 changed files with 680 additions and 78 deletions

View File

@@ -51,6 +51,20 @@ function mapApplicationErrorToMessage(error: { details?: { message?: string } }
return error?.details?.message ?? fallback;
}
function inferDemoRoleFromEmail(email: string): AuthSessionDTO['user']['role'] | undefined {
const normalized = email.trim().toLowerCase();
if (normalized === 'demo.driver@example.com') return 'driver';
if (normalized === 'demo.sponsor@example.com') return 'sponsor';
if (normalized === 'demo.owner@example.com') return 'league-owner';
if (normalized === 'demo.steward@example.com') return 'league-steward';
if (normalized === 'demo.admin@example.com') return 'league-admin';
if (normalized === 'demo.systemowner@example.com') return 'system-owner';
if (normalized === 'demo.superadmin@example.com') return 'super-admin';
return undefined;
}
export class AuthService {
constructor(
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@@ -111,10 +125,12 @@ export class AuthService {
}
const userDTO = this.authSessionPresenter.responseModel;
const inferredRole = inferDemoRoleFromEmail(userDTO.email);
const session = await this.identitySessionPort.createSession({
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
...(inferredRole ? { role: inferredRole } : {}),
});
return {
@@ -144,12 +160,15 @@ export class AuthService {
const sessionOptions = params.rememberMe !== undefined
? { rememberMe: params.rememberMe }
: undefined;
const inferredRole = inferDemoRoleFromEmail(userDTO.email);
const session = await this.identitySessionPort.createSession(
{
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
...(inferredRole ? { role: inferredRole } : {}),
},
sessionOptions
);

View File

@@ -250,10 +250,21 @@ export const routeMatchers = {
*/
isPublic(path: string): boolean {
const publicPatterns = this.getPublicPatterns();
// Check exact matches
if (publicPatterns.includes(path)) return true;
// Treat top-level detail pages as public (e2e relies on this)
// Examples: /leagues/:id, /races/:id, /drivers/:id, /teams/:id
const segments = path.split('/').filter(Boolean);
if (segments.length === 2) {
const [group, slug] = segments;
if (group === 'leagues' && slug !== 'create') return true;
if (group === 'races') return true;
if (group === 'drivers') return true;
if (group === 'teams') return true;
}
// Check parameterized patterns
return publicPatterns.some(pattern => {
if (pattern.includes('[')) {
@@ -277,7 +288,9 @@ export const routeMatchers = {
*/
requiresRole(path: string): string[] | null {
if (this.isInGroup(path, 'admin')) {
return ['owner', 'admin'];
// Website session roles come from the API and are more specific than just "admin".
// Keep "admin"/"owner" for backwards compatibility.
return ['admin', 'owner', 'league-admin', 'league-steward', 'league-owner', 'system-owner', 'super-admin'];
}
if (this.isInGroup(path, 'sponsor')) {
return ['sponsor'];
@@ -301,7 +314,7 @@ export const routeMatchers = {
export function buildPath(
routeName: string,
params: Record<string, string> = {},
locale?: string
_locale?: string
): string {
// This is a placeholder for future i18n implementation
// For now, it just builds the path using the route config
@@ -318,10 +331,11 @@ export function buildPath(
if (typeof route === 'function') {
const paramKeys = Object.keys(params);
if (paramKeys.length === 0) {
const paramKey = paramKeys[0];
if (!paramKey) {
throw new Error(`Route ${routeName} requires parameters`);
}
return route(params[paramKeys[0]]);
return route(params[paramKey]);
}
return route as string;

View File

@@ -0,0 +1,365 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NextRequest } from 'next/server';
const mockGetSession = vi.fn();
// Mock Next.js server components
vi.mock('next/server', () => ({
NextResponse: {
next: vi.fn(() => ({
headers: {
set: vi.fn(),
},
})),
redirect: vi.fn((url: URL) => ({
headers: {
set: vi.fn(),
},
url: url.toString(),
})),
},
}));
// Mock SessionGateway so tests can control session behavior via `mockGetSession`.
vi.mock('@/lib/gateways/SessionGateway', () => ({
SessionGateway: class {
getSession = mockGetSession;
},
}));
vi.mock('@/lib/auth/RouteAccessPolicy', () => ({
RouteAccessPolicy: vi.fn().mockImplementation(() => ({
isPublic: vi.fn(),
isAuthPage: vi.fn(),
requiredRoles: vi.fn(),
})),
}));
vi.mock('@/lib/auth/PathnameInterpreter', () => ({
PathnameInterpreter: vi.fn().mockImplementation(() => ({
interpret: vi.fn((pathname: string) => ({
locale: null,
logicalPathname: pathname,
})),
})),
}));
vi.mock('@/lib/auth/AuthRedirectBuilder', () => ({
AuthRedirectBuilder: vi.fn().mockImplementation(() => ({
toLogin: vi.fn(({ currentPathname }) => `/auth/login?returnTo=${encodeURIComponent(currentPathname)}`),
awayFromAuthPage: vi.fn(({ session }) => {
const role = session.user?.role;
if (role === 'sponsor') return '/sponsor/dashboard';
if (role === 'admin' || role === 'league-admin' || role === 'league-steward' || role === 'league-owner' || role === 'system-owner' || role === 'super-admin') return '/admin';
return '/dashboard';
}),
})),
}));
vi.mock('@/lib/auth/ReturnToSanitizer', () => ({
ReturnToSanitizer: vi.fn().mockImplementation(() => ({
sanitizeReturnTo: vi.fn((input, fallback) => input || fallback),
})),
}));
vi.mock('@/lib/auth/RoutePathBuilder', () => ({
RoutePathBuilder: vi.fn().mockImplementation(() => ({
build: vi.fn((routeId, params, options) => {
const paths: Record<string, string> = {
'auth.login': '/auth/login',
'protected.dashboard': '/dashboard',
'sponsor.dashboard': '/sponsor/dashboard',
'admin': '/admin',
};
const path = paths[routeId] || '/';
return options?.locale ? `/${options.locale}${path}` : path;
}),
})),
}));
vi.mock('@/lib/routing/RouteConfig', () => ({
routes: {
auth: { login: '/auth/login', signup: '/auth/signup', forgotPassword: '/auth/forgot-password', resetPassword: '/auth/reset-password' },
public: { home: '/', leagues: '/leagues', drivers: '/drivers', teams: '/teams', leaderboards: '/leaderboards', races: '/races', sponsorSignup: '/sponsor/signup' },
protected: { dashboard: '/dashboard', onboarding: '/onboarding', profile: '/profile', profileSettings: '/profile/settings' },
sponsor: { root: '/sponsor', dashboard: '/sponsor/dashboard', billing: '/sponsor/billing' },
admin: { root: '/admin', users: '/admin/users' },
league: { detail: (id: string) => `/leagues/${id}`, scheduleAdmin: (id: string) => `/leagues/${id}/schedule/admin` },
race: { root: '/races', detail: (id: string) => `/races/${id}`, stewarding: (id: string) => `/races/${id}/stewarding` },
team: { root: '/teams', detail: (id: string) => `/teams/${id}` },
driver: { root: '/drivers', detail: (id: string) => `/drivers/${id}` },
error: { notFound: '/404', serverError: '/500' },
},
routeMatchers: {
isInGroup: (path: string, group: string) => {
const groups: Record<string, string[]> = {
auth: ['/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/reset-password'],
sponsor: ['/sponsor', '/sponsor/dashboard', '/sponsor/billing'],
admin: ['/admin', '/admin/users'],
};
return groups[group]?.some((prefix) => path.startsWith(prefix)) ?? false;
},
isPublic: (path: string) => {
const publicExact = new Set([
'/',
'/leagues',
'/drivers',
'/teams',
'/leaderboards',
'/races',
'/sponsor/signup',
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
'/404',
'/500',
]);
// Check exact matches
if (publicExact.has(path)) return true;
// Treat top-level detail pages as public (e2e relies on this)
// Examples: /leagues/:id, /races/:id, /drivers/:id, /teams/:id
const segments = path.split('/').filter(Boolean);
if (segments.length === 2) {
const [group, slug] = segments;
if (group === 'leagues' && slug !== 'create') return true;
if (group === 'races') return true;
if (group === 'drivers') return true;
if (group === 'teams') return true;
}
return false;
},
requiresAuth: (path: string) => {
const publicPaths = ['/', '/leagues', '/drivers', '/teams', '/leaderboards', '/races', '/sponsor/signup', '/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/reset-password', '/404', '/500'];
return !publicPaths.includes(path) && !path.startsWith('/leagues/') && !path.startsWith('/drivers/') && !path.startsWith('/teams/') && !path.startsWith('/races/');
},
requiresRole: (path: string) => {
if (path.startsWith('/admin')) return ['league-admin'];
if (path.startsWith('/sponsor')) return ['sponsor'];
return null;
},
},
}));
// Import middleware after mocks
import { middleware } from './middleware';
describe('Middleware - Route Protection', () => {
let mockRequest: NextRequest;
beforeEach(() => {
vi.clearAllMocks();
mockGetSession.mockReset();
mockRequest = {
url: 'http://localhost:3000',
nextUrl: { pathname: '/' },
headers: {
set: vi.fn(),
get: vi.fn(),
},
cookies: {
get: vi.fn(),
getAll: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
},
} as any;
});
describe('x-pathname header', () => {
it('should set x-pathname header for all requests', async () => {
mockRequest.nextUrl.pathname = '/dashboard';
mockGetSession.mockResolvedValue(null); // No session to trigger redirect
const response = await middleware(mockRequest);
// The response should have headers.set called
// For redirect responses, check that the mock was set up correctly
expect(response.headers).toBeDefined();
expect(response.headers.set).toBeDefined();
});
});
describe('Public routes', () => {
it('should allow access to public routes without authentication', async () => {
const publicRoutes = ['/', '/leagues', '/drivers', '/teams', '/leaderboards', '/races', '/sponsor/signup'];
for (const route of publicRoutes) {
mockRequest.nextUrl.pathname = route;
const response = await middleware(mockRequest);
// Should call NextResponse.next() (no redirect)
expect(response).toBeDefined();
}
});
});
describe('Protected routes without authentication', () => {
it('should redirect to login with returnTo parameter', async () => {
mockRequest.nextUrl.pathname = '/dashboard';
mockGetSession.mockResolvedValue(null);
const response = await middleware(mockRequest);
expect(response.url).toContain('/auth/login');
expect(response.url).toContain('returnTo=%2Fdashboard');
});
});
describe('Protected routes with authentication', () => {
it('should allow access to protected routes with valid session', async () => {
mockRequest.nextUrl.pathname = '/dashboard';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
});
const response = await middleware(mockRequest);
expect(response.url).toBeUndefined();
});
it('should redirect authenticated users away from auth pages', async () => {
mockRequest.nextUrl.pathname = '/auth/login';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
});
const response = await middleware(mockRequest);
expect(response.url).toContain('/dashboard');
});
});
describe('Role-based access control', () => {
it('should allow admin user to access admin routes', async () => {
mockRequest.nextUrl.pathname = '/admin/users';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', role: 'league-admin', email: 'test@example.com', displayName: 'Test User' },
});
const response = await middleware(mockRequest);
expect(response.url).toBeUndefined();
});
it('should block regular user from admin routes', async () => {
mockRequest.nextUrl.pathname = '/admin/users';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
});
const response = await middleware(mockRequest);
expect(response.url).toContain('/auth/login');
});
it('should allow sponsor user to access sponsor routes', async () => {
mockRequest.nextUrl.pathname = '/sponsor/dashboard';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', role: 'sponsor', email: 'test@example.com', displayName: 'Test User' },
});
const response = await middleware(mockRequest);
expect(response.url).toBeUndefined();
});
it('should block regular user from sponsor routes', async () => {
mockRequest.nextUrl.pathname = '/sponsor/dashboard';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', role: 'user', email: 'test@example.com', displayName: 'Test User' },
});
const response = await middleware(mockRequest);
expect(response.url).toContain('/auth/login');
});
});
describe('Parameterized routes', () => {
it('should allow public access to parameterized public routes', async () => {
mockRequest.nextUrl.pathname = '/leagues/123';
const response = await middleware(mockRequest);
expect(response).toBeDefined();
});
it('should redirect parameterized protected routes without auth', async () => {
mockRequest.nextUrl.pathname = '/leagues/123/schedule/admin';
mockGetSession.mockResolvedValue(null);
const response = await middleware(mockRequest);
expect(response.url).toContain('/auth/login');
expect(response.url).toContain('returnTo=%2Fleagues%2F123%2Fschedule%2Fadmin');
});
it('should allow admin access to parameterized admin routes', async () => {
mockRequest.nextUrl.pathname = '/leagues/123/schedule/admin';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', role: 'league-admin', email: 'test@example.com', displayName: 'Test User' },
});
const response = await middleware(mockRequest);
expect(response.url).toBeUndefined();
});
});
describe('Edge cases', () => {
it('should handle missing session gracefully', async () => {
mockRequest.nextUrl.pathname = '/dashboard';
mockGetSession.mockResolvedValue(null);
const response = await middleware(mockRequest);
expect(response.url).toContain('/auth/login');
});
it('should handle session without user role', async () => {
mockRequest.nextUrl.pathname = '/admin/users';
mockGetSession.mockResolvedValue({
token: 'test-token',
user: { userId: '123', email: 'test@example.com', displayName: 'Test User' }, // no role
});
const response = await middleware(mockRequest);
expect(response.url).toContain('/auth/login');
});
it('should preserve locale in redirects', async () => {
mockRequest.nextUrl.pathname = '/de/dashboard';
mockGetSession.mockResolvedValue(null);
const response = await middleware(mockRequest);
expect(response.url).toContain('/de/auth/login');
});
});
});

View File

@@ -1,19 +1,115 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { routes, routeMatchers } from '@/lib/routing/RouteConfig';
/**
* Minimal middleware that only sets x-pathname header
* All auth/role/demo logic has been removed
* Server-side route protection middleware
*
* This middleware provides comprehensive route protection by:
* 1. Setting x-pathname header for layout-level protection
* 2. Checking authentication status via SessionGateway
* 3. Redirecting unauthenticated users from protected routes
* 4. Redirecting authenticated users away from auth pages
* 5. Handling role-based access control
*/
export function middleware(request: NextRequest) {
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Set x-pathname header for layout-level protection
const response = NextResponse.next();
response.headers.set('x-pathname', pathname);
// Get session first (needed for all auth-related decisions)
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
// Auth pages (login, signup, etc.) - handle before public check
if (routeMatchers.isInGroup(pathname, 'auth')) {
if (session) {
// User is authenticated, redirect away from auth page
const role = session.user?.role || 'driver';
const redirectPath = getHomePathForRole(role);
// Preserve locale if present in the original path
const localeMatch = pathname.match(/^\/([a-z]{2})\//);
if (localeMatch) {
const locale = localeMatch[1];
return NextResponse.redirect(new URL(`/${locale}${redirectPath}`, request.url));
}
return NextResponse.redirect(new URL(redirectPath, request.url));
}
// Unauthenticated users can access auth pages
return response;
}
// Public routes (no auth required, but not auth pages)
if (routeMatchers.isPublic(pathname)) {
return response;
}
// Protected routes (require authentication)
if (!session) {
// No session, redirect to login
// Preserve locale if present in the path
const localeMatch = pathname.match(/^\/([a-z]{2})\//);
const locale = localeMatch ? localeMatch[1] : null;
const redirectUrl = new URL(routes.auth.login, request.url);
redirectUrl.searchParams.set('returnTo', pathname);
// If locale is present, include it in the redirect
if (locale) {
redirectUrl.pathname = `/${locale}${redirectUrl.pathname}`;
}
return NextResponse.redirect(redirectUrl);
}
// Role-based access control
const requiredRoles = routeMatchers.requiresRole(pathname);
if (requiredRoles) {
const userRole = session.user?.role;
if (!userRole || !requiredRoles.includes(userRole)) {
// User doesn't have required role or no role at all, redirect to login
// Preserve locale if present in the path
const localeMatch = pathname.match(/^\/([a-z]{2})\//);
const locale = localeMatch ? localeMatch[1] : null;
const redirectUrl = new URL(routes.auth.login, request.url);
redirectUrl.searchParams.set('returnTo', pathname);
if (locale) {
redirectUrl.pathname = `/${locale}${redirectUrl.pathname}`;
}
return NextResponse.redirect(redirectUrl);
}
}
// All checks passed, allow access
return response;
}
/**
* Get the home path for a specific role
*/
function getHomePathForRole(role: string): string {
const roleHomeMap: Record<string, string> = {
'driver': routes.protected.dashboard,
'sponsor': routes.sponsor.dashboard,
'league-admin': routes.admin.root,
'league-steward': routes.admin.root,
'league-owner': routes.admin.root,
'system-owner': routes.admin.root,
'super-admin': routes.admin.root,
};
return roleHomeMap[role] || routes.protected.dashboard;
}
/**
* Configure which routes the middleware should run on
*/