365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
}); |