clean routes

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

View File

@@ -0,0 +1,362 @@
import { test, expect } from '@playwright/test';
import {
setWebsiteAuthContext,
} from './websiteAuth';
import {
getWebsiteRouteInventory,
resolvePathTemplate,
} from './websiteRouteInventory';
/**
* Website Authentication Flow Integration Tests
*
* These tests verify the complete authentication flow including:
* - Middleware route protection
* - AuthGuard component functionality
* - Session management and loading states
* - Role-based access control
* - Auth state transitions
* - API integration
*/
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
test.describe('Website Auth Flow - Middleware Protection', () => {
const routes = getWebsiteRouteInventory();
// Test public routes are accessible without auth
test('public routes are accessible without authentication', async ({ page, context }) => {
const publicRoutes = routes.filter(r => r.access === 'public');
expect(publicRoutes.length).toBeGreaterThan(0);
for (const route of publicRoutes.slice(0, 5)) { // Test first 5 to keep test fast
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}${resolvedPath}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
// Test protected routes redirect unauthenticated users
test('protected routes redirect unauthenticated users to login', async ({ page, context }) => {
const protectedRoutes = routes.filter(r => r.access !== 'public');
expect(protectedRoutes.length).toBeGreaterThan(0);
for (const route of protectedRoutes.slice(0, 3)) { // Test first 3
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}${resolvedPath}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(resolvedPath);
}
});
// Test authenticated users can access protected routes
test('authenticated users can access protected routes', async ({ page, context }) => {
const authRoutes = routes.filter(r => r.access === 'auth');
expect(authRoutes.length).toBeGreaterThan(0);
for (const route of authRoutes.slice(0, 3)) {
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
await setWebsiteAuthContext(context, 'auth');
const response = await page.goto(`${getWebsiteBaseUrl()}${resolvedPath}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
});
test.describe('Website Auth Flow - AuthGuard Component', () => {
test('dashboard route shows loading state then content', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const navigationPromise = page.waitForNavigation({ waitUntil: 'domcontentloaded' });
await page.goto(`${getWebsiteBaseUrl()}/dashboard`);
await navigationPromise;
// Should show loading briefly then render dashboard
await expect(page.locator('body')).toBeVisible();
expect(page.url()).toContain('/dashboard');
});
test('dashboard redirects unauthenticated users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login with returnTo parameter
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/dashboard');
});
test('admin routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
// Should redirect to login (no admin role)
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain('/admin');
await expect(page.locator('body')).toBeVisible();
});
test('sponsor routes require sponsor role', async ({ page, context }) => {
// Test as driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as sponsor (should be allowed)
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain('/sponsor/dashboard');
await expect(page.locator('body')).toBeVisible();
});
});
test.describe('Website Auth Flow - Session Management', () => {
test('session is properly loaded on page visit', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Visit dashboard
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Verify session is available by checking for user-specific content
// (This would depend on your actual UI, but we can verify no errors)
await expect(page.locator('body')).toBeVisible();
});
test('logout clears session and redirects', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Go to dashboard
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('body')).toBeVisible();
// Find and click logout (assuming it exists)
// This test would need to be adapted based on actual logout implementation
// For now, we'll test that clearing cookies works
await context.clearCookies();
await page.reload({ waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('auth state transitions work correctly', async ({ page, context }) => {
// Start unauthenticated
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
// Simulate login by setting auth context
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/dashboard');
// Simulate logout
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
});
});
test.describe('Website Auth Flow - API Integration', () => {
test('session endpoint returns correct data', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Direct API call to verify session endpoint
const response = await page.request.get(`${getWebsiteBaseUrl()}/api/auth/session`);
expect(response.ok()).toBe(true);
const session = await response.json();
expect(session).toBeDefined();
});
test('demo login flow works', async ({ page, context }) => {
// Clear any existing cookies
await context.clearCookies();
// Navigate to login page
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Verify login page loads
await expect(page.locator('body')).toBeVisible();
// Note: Actual demo login form interaction would go here
// For now, we'll test the API endpoint directly
const response = await page.request.post(`${getWebsiteBaseUrl()}/api/auth/demo-login`, {
data: { role: 'driver' }
});
expect(response.ok()).toBe(true);
// Verify cookies were set
const cookies = await context.cookies();
const gpSession = cookies.find(c => c.name === 'gp_session');
expect(gpSession).toBeDefined();
});
test('auth API handles different roles correctly', async ({ page }) => {
const roles = ['driver', 'sponsor', 'admin'] as const;
for (const role of roles) {
const response = await page.request.post(`${getWebsiteBaseUrl()}/api/auth/demo-login`, {
data: { role }
});
expect(response.ok()).toBe(true);
const session = await response.json();
expect(session.user).toBeDefined();
// Verify role-specific data
if (role === 'sponsor') {
expect(session.user.sponsorId).toBeDefined();
}
}
});
});
test.describe('Website Auth Flow - Edge Cases', () => {
test('handles auth state drift gracefully', async ({ page, context }) => {
// Set sponsor context but with missing sponsor ID
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'missing-sponsor-id' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login due to invalid session
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles expired session', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'expired' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles invalid session cookie', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'invalid-cookie' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('public routes accessible even with invalid auth cookies', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public', { sessionDrift: 'invalid-cookie' });
await page.goto(`${getWebsiteBaseUrl()}/leagues`, { waitUntil: 'domcontentloaded' });
// Should still work
expect(page.url()).toContain('/leagues');
await expect(page.locator('body')).toBeVisible();
});
});
test.describe('Website Auth Flow - Redirect Scenarios', () => {
test('auth routes redirect authenticated users away', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Try to access login page while authenticated
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Should redirect to dashboard
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/dashboard');
});
test('sponsor auth routes redirect to sponsor dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Should redirect to sponsor dashboard
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
});
test('returnTo parameter works correctly', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const targetRoute = '/leagues/league-1/settings';
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
// Should redirect to login with returnTo
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(targetRoute);
// After login, should return to target
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(targetRoute);
});
});
test.describe('Website Auth Flow - Performance', () => {
test('auth verification completes quickly', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const startTime = Date.now();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const endTime = Date.now();
// Should complete within reasonable time (under 5 seconds)
expect(endTime - startTime).toBeLessThan(5000);
// Should show content
await expect(page.locator('body')).toBeVisible();
});
test('no infinite loading states', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Monitor for loading indicators
let loadingCount = 0;
page.on('request', (req) => {
if (req.url().includes('/auth/session')) loadingCount++;
});
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'networkidle' });
// Should not make excessive session calls
expect(loadingCount).toBeLessThan(3);
// Should eventually show content
await expect(page.locator('body')).toBeVisible();
});
});

View File

@@ -0,0 +1,343 @@
import { test, expect } from '@playwright/test';
import { setWebsiteAuthContext } from './websiteAuth';
/**
* Website AuthGuard Component Tests
*
* These tests verify the AuthGuard component behavior:
* - Loading states during session verification
* - Redirect behavior for unauthorized access
* - Role-based access control
* - Component rendering with different auth states
*/
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
test.describe('AuthGuard Component - Loading States', () => {
test('shows loading state during session verification', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Monitor for loading indicators
page.on('request', async (req) => {
if (req.url().includes('/auth/session')) {
// Check if loading indicator is visible during session fetch
await page.locator('text=/Verifying authentication|Loading/').isVisible().catch(() => false);
}
});
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should eventually show dashboard content
await expect(page.locator('body')).toBeVisible();
expect(page.url()).toContain('/dashboard');
});
test('handles rapid auth state changes', async ({ page, context }) => {
// Start unauthenticated
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
// Quickly switch to authenticated
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/dashboard');
// Quickly switch back
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
});
test('handles session fetch failures gracefully', async ({ page, context }) => {
// Clear cookies to simulate session fetch returning null
await context.clearCookies();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
});
test.describe('AuthGuard Component - Redirect Behavior', () => {
test('redirects to login with returnTo parameter', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const protectedRoutes = [
'/dashboard',
'/profile',
'/leagues/league-1/settings',
'/sponsor/dashboard',
];
for (const route of protectedRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('redirects back to protected route after login', async ({ page, context }) => {
const targetRoute = '/leagues/league-1/settings';
// Start unauthenticated, try to access protected route
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
// Verify redirect to login
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(targetRoute);
// Simulate login by switching auth context
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
// Should be on target route
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe(targetRoute);
});
test('handles auth routes when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Try to access login page while authenticated
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Should redirect to dashboard
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/dashboard');
});
test('sponsor auth routes redirect to sponsor dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
});
});
test.describe('AuthGuard Component - Role-Based Access', () => {
test('admin routes allow admin users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'admin');
const adminRoutes = ['/admin', '/admin/users'];
for (const route of adminRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
test('admin routes block non-admin users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const adminRoutes = ['/admin', '/admin/users'];
for (const route of adminRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
}
});
test('sponsor routes allow sponsor users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
const sponsorRoutes = ['/sponsor', '/sponsor/dashboard', '/sponsor/settings'];
for (const route of sponsorRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
test('sponsor routes block non-sponsor users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const sponsorRoutes = ['/sponsor', '/sponsor/dashboard', '/sponsor/settings'];
for (const route of sponsorRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
}
});
test('league admin routes require league admin role', async ({ page, context }) => {
// Test as regular driver
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (has access to league admin routes)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/leagues/league-1/settings');
});
test('authenticated users can access auth-required routes', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const authRoutes = ['/dashboard', '/profile', '/onboarding'];
for (const route of authRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
});
test.describe('AuthGuard Component - Component Rendering', () => {
test('renders protected content when access granted', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should render the dashboard content
await expect(page.locator('body')).toBeVisible();
// Should not show loading or redirect messages
const loadingText = await page.locator('text=/Verifying authentication|Redirecting/').count();
expect(loadingText).toBe(0);
});
test('shows redirect message briefly before redirect', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
// This is hard to catch, but we can verify the final state
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should end up at login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles multiple AuthGuard instances on same page', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Visit a page that might have nested AuthGuards
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1`, { waitUntil: 'domcontentloaded' });
// Should render correctly
await expect(page.locator('body')).toBeVisible();
expect(page.url()).toContain('/leagues/league-1');
});
test('preserves child component state during auth checks', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Visit dashboard
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should maintain component state (no full page reload)
// This is verified by the fact that the page loads without errors
await expect(page.locator('body')).toBeVisible();
});
});
test.describe('AuthGuard Component - Error Handling', () => {
test('handles network errors during session check', async ({ page, context }) => {
// Clear cookies to simulate failed session check
await context.clearCookies();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles invalid session data', async ({ page, context }) => {
// Set invalid session cookie
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'invalid-cookie' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles expired session', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'expired' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles missing required role data', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'missing-sponsor-id' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
});
test.describe('AuthGuard Component - Performance', () => {
test('auth check completes within reasonable time', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const startTime = Date.now();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const endTime = Date.now();
// Should complete within 5 seconds
expect(endTime - startTime).toBeLessThan(5000);
});
test('no excessive session checks', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
let sessionCheckCount = 0;
page.on('request', (req) => {
if (req.url().includes('/auth/session')) {
sessionCheckCount++;
}
});
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'networkidle' });
// Should check session once or twice (initial + maybe one refresh)
expect(sessionCheckCount).toBeLessThan(3);
});
test('handles concurrent route access', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Navigate to multiple routes rapidly
const routes = ['/dashboard', '/profile', '/leagues', '/dashboard'];
for (const route of routes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(route);
}
});
});

View File

@@ -0,0 +1,438 @@
import { test, expect } from '@playwright/test';
import { setWebsiteAuthContext } from './websiteAuth';
/**
* Website Middleware Route Protection Tests
*
* These tests specifically verify the Next.js middleware behavior:
* - Public routes are always accessible
* - Protected routes require authentication
* - Auth routes redirect authenticated users
* - Role-based access control
*/
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
test.describe('Website Middleware - Public Route Protection', () => {
test('root route is publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('league routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const routes = ['/leagues', '/leagues/league-1', '/leagues/league-1/standings'];
for (const route of routes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
test('driver routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/drivers/driver-1`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('team routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/teams/team-1`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('race routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/races/race-1`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('leaderboard routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/leaderboards`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('sponsor signup is publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/sponsor/signup`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('auth routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/iracing',
];
for (const route of authRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
});
test.describe('Website Middleware - Protected Route Protection', () => {
test('dashboard redirects unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/dashboard');
});
test('profile routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const profileRoutes = ['/profile', '/profile/settings', '/profile/leagues'];
for (const route of profileRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('admin routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const adminRoutes = ['/admin', '/admin/users'];
for (const route of adminRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('sponsor routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const sponsorRoutes = ['/sponsor', '/sponsor/dashboard', '/sponsor/settings'];
for (const route of sponsorRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('league admin routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const leagueAdminRoutes = [
'/leagues/league-1/roster/admin',
'/leagues/league-1/schedule/admin',
'/leagues/league-1/settings',
'/leagues/league-1/stewarding',
'/leagues/league-1/wallet',
];
for (const route of leagueAdminRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('onboarding redirects unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/onboarding`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/onboarding');
});
});
test.describe('Website Middleware - Authenticated Access', () => {
test('dashboard is accessible when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const response = await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain('/dashboard');
await expect(page.locator('body')).toBeVisible();
});
test('profile routes are accessible when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const profileRoutes = ['/profile', '/profile/settings', '/profile/leagues'];
for (const route of profileRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
test('onboarding is accessible when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const response = await page.goto(`${getWebsiteBaseUrl()}/onboarding`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain('/onboarding');
});
});
test.describe('Website Middleware - Role-Based Access', () => {
test('admin routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/admin');
});
test('sponsor routes require sponsor role', async ({ page, context }) => {
// Test as driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as sponsor (should be allowed)
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
});
test('league admin routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/leagues/league-1/settings');
});
test('race stewarding routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/races/race-1/stewarding`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/races/race-1/stewarding`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/races/race-1/stewarding');
});
});
test.describe('Website Middleware - Auth Route Behavior', () => {
test('auth routes redirect authenticated users away', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/iracing',
];
for (const route of authRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/dashboard');
}
});
test('sponsor auth routes redirect to sponsor dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/iracing',
];
for (const route of authRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
}
});
test('admin auth routes redirect to admin dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'admin');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/iracing',
];
for (const route of authRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/admin');
}
});
});
test.describe('Website Middleware - Edge Cases', () => {
test('handles trailing slashes correctly', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const routes = [
{ path: '/leagues', expected: '/leagues' },
{ path: '/leagues/', expected: '/leagues' },
{ path: '/drivers/driver-1', expected: '/drivers/driver-1' },
{ path: '/drivers/driver-1/', expected: '/drivers/driver-1' },
];
for (const { path, expected } of routes) {
await page.goto(`${getWebsiteBaseUrl()}${path}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(expected);
}
});
test('handles invalid routes gracefully', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const invalidRoutes = [
'/invalid-route',
'/leagues/invalid-id',
'/drivers/invalid-id',
];
for (const route of invalidRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
// Should either show 404 or redirect to a valid page
const status = response?.status();
const url = page.url();
expect([200, 404].includes(status ?? 0) || url.includes('/auth/login')).toBe(true);
}
});
test('preserves query parameters during redirects', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const targetRoute = '/dashboard?tab=settings&filter=active';
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/dashboard?tab=settings&filter=active');
});
test('handles deeply nested routes', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const deepRoute = '/leagues/league-1/stewarding/protests/protest-1';
await page.goto(`${getWebsiteBaseUrl()}${deepRoute}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(deepRoute);
});
});
test.describe('Website Middleware - Performance', () => {
test('middleware adds minimal overhead', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const startTime = Date.now();
await page.goto(`${getWebsiteBaseUrl()}/leagues`, { waitUntil: 'domcontentloaded' });
const endTime = Date.now();
// Should complete quickly (under 3 seconds)
expect(endTime - startTime).toBeLessThan(3000);
});
test('no redirect loops', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
// Try to access a protected route multiple times
for (let i = 0; i < 3; i++) {
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
}
});
test('handles rapid navigation', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Navigate between multiple protected routes rapidly
const routes = ['/dashboard', '/profile', '/leagues', '/dashboard'];
for (const route of routes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(route);
}
});
});

View File

@@ -0,0 +1,184 @@
import type { Page, BrowserContext } from '@playwright/test';
import type { RouteAccess } from './websiteRouteInventory';
export type WebsiteAuthContext = 'public' | 'auth' | 'admin' | 'sponsor';
export type WebsiteSessionDriftMode = 'invalid-cookie' | 'expired' | 'missing-sponsor-id';
export type WebsiteFaultMode = 'null-array' | 'missing-field' | 'invalid-date';
type DemoLoginRole =
| 'driver'
| 'sponsor'
| 'league-owner'
| 'league-steward'
| 'league-admin'
| 'system-owner'
| 'super-admin';
const demoSessionCookieCache = new Map<DemoLoginRole, string>();
export function authContextForAccess(access: RouteAccess): WebsiteAuthContext {
if (access === 'public') return 'public';
if (access === 'auth') return 'auth';
if (access === 'admin') return 'admin';
return 'sponsor';
}
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
function demoLoginRoleForAuthContext(auth: WebsiteAuthContext): DemoLoginRole | null {
switch (auth) {
case 'public':
return null;
case 'auth':
return 'driver';
case 'sponsor':
return 'sponsor';
case 'admin':
// Website "admin" pages need an elevated role; use the strongest demo role.
return 'super-admin';
default: {
const exhaustive: never = auth;
return exhaustive;
}
}
}
function extractCookieValue(setCookieHeader: string, cookieName: string): string | null {
// set-cookie header value: "name=value; Path=/; HttpOnly; ..."
// Do not split on comma (Expires contains commas). Just regex out the first cookie value.
const match = setCookieHeader.match(new RegExp(`(?:^|\\s)${cookieName}=([^;]+)`));
return match?.[1] ?? null;
}
async function ensureDemoSessionCookie(role: DemoLoginRole): Promise<string> {
const cached = demoSessionCookieCache.get(role);
if (cached) return cached;
const baseUrl = getWebsiteBaseUrl();
const url = `${baseUrl}/api/auth/demo-login`;
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ role }),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Smoke demo-login failed for role=${role}. ${response.status} ${response.statusText}. ${body}`);
}
// In Node (playwright runner) `headers.get('set-cookie')` returns a single comma-separated string.
// Parse cookies by splitting on `, ` and taking the first `name=value` segment.
const rawSetCookie = response.headers.get('set-cookie') ?? '';
const cookieHeaderPairs = rawSetCookie
? rawSetCookie
.split(', ')
.map((chunk) => chunk.split(';')[0]?.trim())
.filter(Boolean)
: [];
const gpSessionPair = cookieHeaderPairs.find((pair) => pair.startsWith('gp_session='));
if (!gpSessionPair) {
throw new Error(
`Smoke demo-login did not return gp_session cookie for role=${role}. set-cookie header: ${rawSetCookie}`,
);
}
const gpSessionValue = extractCookieValue(gpSessionPair, 'gp_session');
if (!gpSessionValue) {
throw new Error(
`Smoke demo-login returned a gp_session cookie, but it could not be parsed for role=${role}. Pair: ${gpSessionPair}`,
);
}
demoSessionCookieCache.set(role, gpSessionValue);
return gpSessionValue;
}
export async function setWebsiteAuthContext(
context: BrowserContext,
auth: WebsiteAuthContext,
options: { sessionDrift?: WebsiteSessionDriftMode; faultMode?: WebsiteFaultMode } = {},
): Promise<void> {
const domain = 'localhost';
const base = { domain, path: '/' };
const driftCookie =
options.sessionDrift != null ? [{ ...base, name: 'gridpilot_session_drift', value: String(options.sessionDrift) }] : [];
const faultCookie =
options.faultMode != null ? [{ ...base, name: 'gridpilot_fault_mode', value: String(options.faultMode) }] : [];
await context.clearCookies();
if (auth === 'public') {
// Public access: no session cookie, only drift/fault cookies if specified
await context.addCookies([...driftCookie, ...faultCookie]);
return;
}
const demoRole = demoLoginRoleForAuthContext(auth);
if (!demoRole) {
throw new Error(`Expected a demo role for auth context ${auth}`);
}
const gpSessionValue = await ensureDemoSessionCookie(demoRole);
// Only set gp_session cookie (no demo mode or sponsor cookies)
// For Docker/local testing, ensure cookies work with localhost
const sessionCookie = [{
...base,
name: 'gp_session',
value: gpSessionValue,
httpOnly: true,
secure: false, // Localhost doesn't need HTTPS
sameSite: 'Lax' as const // Ensure compatibility
}];
await context.addCookies([...sessionCookie, ...driftCookie, ...faultCookie]);
}
export type ConsoleCapture = {
consoleErrors: string[];
pageErrors: string[];
};
export function attachConsoleErrorCapture(page: Page): ConsoleCapture {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(String(err));
});
page.on('console', (msg) => {
const type = msg.type();
if (type !== 'error') return;
const text = msg.text();
// Filter known benign warnings (keep small + generic).
if (text.includes('Download the React DevTools')) return;
// Next/Image accessibility warning (not a runtime failure for smoke coverage).
if (text.includes('Image is missing required "alt" property')) return;
// React controlled <select> warning (still renders fine; treat as non-fatal for route coverage).
if (text.includes('Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>.')) return;
consoleErrors.push(`[${type}] ${text}`);
});
return { consoleErrors, pageErrors };
}

View File

@@ -0,0 +1,194 @@
import * as fs from 'fs';
import * as path from 'path';
export type RouteAccess = 'public' | 'auth' | 'admin' | 'sponsor';
export type RouteParams = Record<string, string>;
export type WebsiteRouteDefinition = {
pathTemplate: string;
params?: RouteParams;
access: RouteAccess;
expectedPathTemplate?: string;
allowNotFound?: boolean;
};
function walkDir(rootDir: string): string[] {
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
results.push(...walkDir(fullPath));
continue;
}
results.push(fullPath);
}
return results;
}
function toPathTemplate(appDir: string, pageFilePath: string): string {
const rel = path.relative(appDir, pageFilePath);
const segments = rel.split(path.sep);
// drop trailing "page.tsx"
segments.pop();
// root page.tsx
if (segments.length === 0) return '/';
return `/${segments.join('/')}`;
}
export function listNextAppPageTemplates(appDir?: string): string[] {
const resolvedAppDir = appDir ?? path.join(process.cwd(), 'apps', 'website', 'app');
const files = walkDir(resolvedAppDir);
const pages = files.filter((f) => path.basename(f) === 'page.tsx');
return pages.map((pagePath) => toPathTemplate(resolvedAppDir, pagePath));
}
export function resolvePathTemplate(pathTemplate: string, params: RouteParams = {}): string {
return pathTemplate.replace(/\[([^\]]+)\]/g, (_match, key: string) => {
const replacement = params[key];
if (!replacement) {
throw new Error(`Missing route param "${key}" for template "${pathTemplate}"`);
}
return replacement;
});
}
// Default IDs used to resolve dynamic routes in smoke tests.
// These values must be supported by the docker mock API in docker-compose.test.yml.
const LEAGUE_ID = 'league-1';
const DRIVER_ID = 'driver-1';
const TEAM_ID = 'team-1';
const RACE_ID = 'race-1';
const PROTEST_ID = 'protest-1';
const ROUTE_META: Record<string, Omit<WebsiteRouteDefinition, 'pathTemplate'>> = {
'/': { access: 'public' },
'/404': { access: 'public' },
'/500': { access: 'public' },
'/admin': { access: 'admin' },
'/admin/users': { access: 'admin' },
'/auth/forgot-password': { access: 'public' },
'/auth/iracing': { access: 'public' },
'/auth/login': { access: 'public' },
'/auth/reset-password': { access: 'public' },
'/auth/signup': { access: 'public' },
'/dashboard': { access: 'auth' },
'/drivers': { access: 'public' },
'/drivers/[id]': { access: 'public', params: { id: DRIVER_ID } },
'/leaderboards': { access: 'public' },
'/leaderboards/drivers': { access: 'public' },
'/leagues': { access: 'public' },
'/leagues/create': { access: 'auth' },
'/leagues/[id]': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/roster/admin': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/rulebook': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/schedule': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/schedule/admin': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/settings': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/sponsorships': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/standings': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/stewarding': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/stewarding/protests/[protestId]': {
access: 'admin',
params: { id: LEAGUE_ID, protestId: PROTEST_ID },
},
'/leagues/[id]/wallet': { access: 'admin', params: { id: LEAGUE_ID } },
'/onboarding': { access: 'auth' },
'/profile': { access: 'auth' },
'/profile/leagues': { access: 'auth' },
'/profile/liveries': { access: 'auth' },
'/profile/liveries/upload': { access: 'auth' },
'/profile/settings': { access: 'auth' },
'/profile/sponsorship-requests': { access: 'auth' },
'/races': { access: 'public' },
'/races/all': { access: 'public' },
'/races/[id]': { access: 'public', params: { id: RACE_ID } },
'/races/[id]/results': { access: 'public', params: { id: RACE_ID } },
'/races/[id]/stewarding': { access: 'admin', params: { id: RACE_ID } },
'/sponsor': { access: 'sponsor', expectedPathTemplate: '/sponsor/dashboard' },
'/sponsor/billing': { access: 'sponsor' },
'/sponsor/campaigns': { access: 'sponsor' },
'/sponsor/dashboard': { access: 'sponsor' },
'/sponsor/leagues': { access: 'sponsor' },
'/sponsor/leagues/[id]': { access: 'sponsor', params: { id: LEAGUE_ID } },
'/sponsor/settings': { access: 'sponsor' },
'/sponsor/signup': { access: 'public' },
'/teams': { access: 'public' },
'/teams/leaderboard': { access: 'public' },
'/teams/[id]': { access: 'public', params: { id: TEAM_ID } },
};
export function getWebsiteRouteInventory(): WebsiteRouteDefinition[] {
const discovered = listNextAppPageTemplates();
const missingMeta = discovered.filter((template) => !ROUTE_META[template]);
if (missingMeta.length > 0) {
throw new Error(
`Missing ROUTE_META entries for discovered pages:\n${missingMeta
.slice()
.sort()
.map((t) => `- ${t}`)
.join('\n')}`,
);
}
const extraMeta = Object.keys(ROUTE_META).filter((template) => !discovered.includes(template));
if (extraMeta.length > 0) {
throw new Error(
`ROUTE_META contains templates that are not present as page.tsx routes:\n${extraMeta
.slice()
.sort()
.map((t) => `- ${t}`)
.join('\n')}`,
);
}
return discovered
.slice()
.sort()
.map((pathTemplate) => ({ pathTemplate, ...ROUTE_META[pathTemplate] }));
}
export function getWebsiteParamEdgeCases(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/races/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
{ pathTemplate: '/leagues/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
];
}
export function getWebsiteFaultInjectionRoutes(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/leagues/[id]', params: { id: LEAGUE_ID }, access: 'public' },
{ pathTemplate: '/leagues/[id]/schedule/admin', params: { id: LEAGUE_ID }, access: 'admin' },
{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' },
{ pathTemplate: '/races/[id]', params: { id: RACE_ID }, access: 'public' },
];
}
export function getWebsiteAuthDriftRoutes(): WebsiteRouteDefinition[] {
return [{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' }];
}

View File

@@ -136,6 +136,17 @@ async function runWebsiteSmokeScenario(args: {
if (resourceType === 'fetch' && url.includes('_rsc=')) {
return;
}
// Ignore fetch requests to the expected redirect target during page redirects
// This handles cases like /sponsor -> /sponsor/dashboard where the redirect
// causes an aborted fetch request to the target URL
if (resourceType === 'fetch' && route.expectedPathTemplate) {
const expectedPath = resolvePathTemplate(route.expectedPathTemplate, route.params);
const urlObj = new URL(url);
if (urlObj.pathname === expectedPath) {
return;
}
}
}
requestFailures.push({

View File

@@ -21,44 +21,131 @@ export async function setWebsiteAuthContext(
const domain = 'localhost';
const base = { domain, path: '/' };
// The website uses `gp_session` cookie for authentication and `gridpilot_demo_mode` for identity switching
const cookies =
auth === 'public'
? [
// No gp_session cookie for public access - this allows auth routes to render
{ ...base, name: 'gridpilot_demo_mode', value: 'none' },
{ ...base, name: 'gridpilot_sponsor_id', value: '' },
{ ...base, name: 'gridpilot_sponsor_name', value: '' },
]
: auth === 'sponsor'
? [
{ ...base, name: 'gp_session', value: 'demo-sponsor-session' },
{ ...base, name: 'gridpilot_demo_mode', value: 'sponsor' },
{ ...base, name: 'gridpilot_sponsor_id', value: 'demo-sponsor-1' },
{ ...base, name: 'gridpilot_sponsor_name', value: 'Demo Sponsor' },
]
: auth === 'admin'
? [
{ ...base, name: 'gp_session', value: 'demo-admin-session' },
{ ...base, name: 'gridpilot_demo_mode', value: 'admin' },
{ ...base, name: 'gridpilot_sponsor_id', value: '' },
{ ...base, name: 'gridpilot_sponsor_name', value: '' },
]
: [
{ ...base, name: 'gp_session', value: 'demo-driver-session' },
{ ...base, name: 'gridpilot_demo_mode', value: 'driver' },
{ ...base, name: 'gridpilot_sponsor_id', value: '' },
{ ...base, name: 'gridpilot_sponsor_name', value: '' },
];
const driftCookie =
options.sessionDrift != null ? [{ ...base, name: 'gridpilot_session_drift', value: String(options.sessionDrift) }] : [];
const faultCookie =
options.faultMode != null ? [{ ...base, name: 'gridpilot_fault_mode', value: String(options.faultMode) }] : [];
// The website uses `gp_session` cookie for authentication
// For smoke tests, we now use demo login API to get real session cookies
// instead of static cookie values
if (auth === 'public') {
// No authentication needed
await context.clearCookies();
return;
}
// For authenticated contexts, we need to perform a demo login
// This ensures we get real session cookies with proper structure
// Determine which demo role to use based on auth context
let demoRole: string;
switch (auth) {
case 'sponsor':
demoRole = 'sponsor';
break;
case 'admin':
demoRole = 'league-admin'; // Real admin role from AuthSessionDTO
break;
case 'auth':
default:
demoRole = 'driver';
break;
}
// Call the demo login API directly (not through Next.js rewrite)
// This bypasses any proxy/cookie issues
const response = await fetch('http://localhost:3101/auth/demo-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
role: demoRole,
rememberMe: true
}),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Demo login failed: ${response.status}`);
}
// Extract cookies from the response
const setCookieHeader = response.headers.get('set-cookie');
if (!setCookieHeader) {
throw new Error('No cookies set by demo login');
}
// Parse the Set-Cookie headers
const cookies = setCookieHeader.split(',').map(cookieStr => {
const parts = cookieStr.split(';').map(p => p.trim());
const [nameValue, ...attributes] = parts;
const [name, value] = nameValue.split('=');
const cookie: any = {
name,
value: decodeURIComponent(value),
domain: 'localhost',
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600, // 1 hour
httpOnly: false,
secure: false,
sameSite: 'Lax' as const
};
for (const attr of attributes) {
const [attrName, attrValue] = attr.split('=');
const lowerName = attrName.toLowerCase();
if (lowerName === 'path') cookie.path = attrValue;
else if (lowerName === 'httponly') cookie.httpOnly = true;
else if (lowerName === 'secure') cookie.secure = true;
else if (lowerName === 'samesite') cookie.sameSite = attrValue as any;
else if (lowerName === 'domain') {
// Skip domain from API - we'll use localhost
}
else if (lowerName === 'max-age') cookie.expires = Math.floor(Date.now() / 1000) + parseInt(attrValue);
}
// For Docker/local testing, ensure cookies work with localhost
// Playwright's context.addCookies requires specific settings for localhost
if (cookie.domain === 'localhost') {
cookie.secure = false; // Localhost doesn't need HTTPS
// Keep sameSite as provided by API, but ensure it's compatible
if (cookie.sameSite === 'None') {
// For SameSite=None, we need Secure=true, but localhost doesn't support it
// So we fall back to Lax for local testing
cookie.sameSite = 'Lax';
}
}
return cookie;
});
// Apply session drift or fault modes if specified
if (options.sessionDrift || options.faultMode) {
const sessionCookie = cookies.find(c => c.name === 'gp_session');
if (sessionCookie) {
if (options.sessionDrift) {
sessionCookie.value = `drift-${options.sessionDrift}-${sessionCookie.value}`;
}
if (options.faultMode) {
cookies.push({
name: 'gridpilot_fault_mode',
value: options.faultMode,
domain,
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: false,
secure: false,
sameSite: 'Lax' as const
});
}
}
}
// Clear existing cookies and add the new ones
await context.clearCookies();
await context.addCookies([...cookies, ...driftCookie, ...faultCookie]);
await context.addCookies(cookies);
}
export type ConsoleCapture = {

View File

@@ -83,7 +83,6 @@ const ROUTE_META: Record<string, Omit<WebsiteRouteDefinition, 'pathTemplate'>> =
'/admin/users': { access: 'admin' },
'/auth/forgot-password': { access: 'public' },
'/auth/iracing': { access: 'public' },
'/auth/login': { access: 'public' },
'/auth/reset-password': { access: 'public' },
'/auth/signup': { access: 'public' },