clean routes
This commit is contained in:
@@ -1,350 +0,0 @@
|
||||
/**
|
||||
* TDD Tests for AuthGateway
|
||||
*
|
||||
* These tests verify the authentication gateway logic following TDD principles:
|
||||
* 1. Write failing tests first
|
||||
* 2. Implement minimal code to pass
|
||||
* 3. Refactor while keeping tests green
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AuthGateway, AuthGatewayConfig } from './AuthGateway';
|
||||
import type { AuthContextValue } from '@/lib/auth/AuthContext';
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
|
||||
// Mock SessionViewModel factory
|
||||
function createMockSession(overrides: Partial<SessionViewModel> = {}): SessionViewModel {
|
||||
const baseSession = {
|
||||
isAuthenticated: true,
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: undefined,
|
||||
};
|
||||
|
||||
// Handle the case where overrides might have a user object
|
||||
// (for backward compatibility with existing test patterns)
|
||||
if (overrides.user) {
|
||||
const { user, ...rest } = overrides;
|
||||
return {
|
||||
...baseSession,
|
||||
...rest,
|
||||
userId: user.userId || baseSession.userId,
|
||||
email: user.email || baseSession.email,
|
||||
displayName: user.displayName || baseSession.displayName,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseSession,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock AuthContext factory
|
||||
function createMockAuthContext(overrides: Partial<AuthContextValue> = {}): AuthContextValue {
|
||||
return {
|
||||
session: null,
|
||||
loading: false,
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
refreshSession: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthGateway', () => {
|
||||
describe('Basic Authentication', () => {
|
||||
it('should allow access when user is authenticated with no role requirements', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession(),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(gateway.canAccess()).toBe(true);
|
||||
expect(gateway.isAuthenticated()).toBe(true);
|
||||
expect(gateway.isLoading()).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access when user is not authenticated', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(gateway.canAccess()).toBe(false);
|
||||
expect(gateway.isAuthenticated()).toBe(false);
|
||||
expect(gateway.isLoading()).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access when auth context is loading', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: true,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(gateway.canAccess()).toBe(false);
|
||||
expect(gateway.isLoading()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role-Based Access Control', () => {
|
||||
// Note: AuthorizationBlocker currently returns 'enabled' for all authenticated users
|
||||
// in demo mode. These tests document the intended behavior for when role-based
|
||||
// access control is fully implemented.
|
||||
it('should allow access when user has required role', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession({
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: 'admin',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
requiredRoles: ['admin'],
|
||||
});
|
||||
|
||||
expect(gateway.canAccess()).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny access when user lacks required role', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession({
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
displayName: 'Regular User',
|
||||
role: 'user',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
requiredRoles: ['admin'],
|
||||
});
|
||||
|
||||
expect(gateway.canAccess()).toBe(false);
|
||||
expect(gateway.getBlockMessage()).toContain('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redirect Configuration', () => {
|
||||
it('should use default redirect path when not specified', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(gateway.getUnauthorizedRedirectPath()).toBe('/auth/login');
|
||||
});
|
||||
|
||||
it('should use custom redirect path when specified', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
unauthorizedRedirectPath: '/custom-login',
|
||||
});
|
||||
|
||||
expect(gateway.getUnauthorizedRedirectPath()).toBe('/custom-login');
|
||||
});
|
||||
|
||||
it('should respect redirectOnUnauthorized configuration', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
redirectOnUnauthorized: false,
|
||||
});
|
||||
|
||||
expect(gateway.redirectIfUnauthorized()).toBe(false);
|
||||
});
|
||||
|
||||
it('should indicate redirect is needed when unauthorized and redirect enabled', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
redirectOnUnauthorized: true,
|
||||
});
|
||||
|
||||
expect(gateway.redirectIfUnauthorized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access State', () => {
|
||||
it('should return complete access state', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession(),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
const state = gateway.getAccessState();
|
||||
|
||||
expect(state).toEqual({
|
||||
canAccess: true,
|
||||
reason: 'Access granted',
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return loading state correctly', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: true,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
const state = gateway.getAccessState();
|
||||
|
||||
expect(state.isLoading).toBe(true);
|
||||
expect(state.canAccess).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Refresh', () => {
|
||||
it('should update access state after session refresh', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(gateway.canAccess()).toBe(false);
|
||||
|
||||
// Simulate session refresh
|
||||
authContext.session = createMockSession();
|
||||
gateway.refresh();
|
||||
|
||||
expect(gateway.canAccess()).toBe(true);
|
||||
expect(gateway.isAuthenticated()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined session gracefully', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: undefined as any,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(gateway.canAccess()).toBe(false);
|
||||
expect(gateway.isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty required roles array', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession(),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
requiredRoles: [],
|
||||
});
|
||||
|
||||
expect(gateway.canAccess()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle session with no user object', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: {
|
||||
isAuthenticated: true,
|
||||
user: null as any,
|
||||
},
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(gateway.canAccess()).toBe(true); // Authenticated but no user
|
||||
expect(gateway.isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity in role matching', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession({
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: 'ADMIN', // uppercase
|
||||
},
|
||||
}),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
requiredRoles: ['admin'], // lowercase
|
||||
});
|
||||
|
||||
// Role matching is case-sensitive
|
||||
expect(gateway.canAccess()).toBe(false);
|
||||
expect(gateway.getBlockMessage()).toContain('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error when enforceAccess is called without access', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(() => gateway.enforceAccess()).toThrow('Access denied');
|
||||
});
|
||||
|
||||
it('should not throw error when enforceAccess is called with access', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession(),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
expect(() => gateway.enforceAccess()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Block Messages', () => {
|
||||
it('should provide appropriate block message for unauthenticated user', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
const message = gateway.getBlockMessage();
|
||||
// Current behavior: AuthorizationBlocker returns "You must be logged in to access this area."
|
||||
expect(message).toContain('logged in');
|
||||
});
|
||||
|
||||
it('should provide appropriate block message for missing roles', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: createMockSession({
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
displayName: 'Regular User',
|
||||
role: 'user',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {
|
||||
requiredRoles: ['admin'],
|
||||
});
|
||||
|
||||
const canAccess = gateway.canAccess();
|
||||
const state = gateway.getAccessState();
|
||||
|
||||
expect(canAccess).toBe(false);
|
||||
expect(state.reason).toContain('admin');
|
||||
});
|
||||
|
||||
it('should provide appropriate block message when loading', () => {
|
||||
const authContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: true,
|
||||
});
|
||||
const gateway = new AuthGateway(authContext, {});
|
||||
|
||||
const message = gateway.getBlockMessage();
|
||||
// Current behavior: AuthorizationBlocker returns "You must be logged in to access this area."
|
||||
expect(message).toContain('logged in');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* Gateway: AuthGateway
|
||||
*
|
||||
* Component-based gateway that manages authentication state and access control.
|
||||
* Follows clean architecture by orchestrating between auth context and blockers.
|
||||
*
|
||||
* Gateways are the entry point for component-level access control.
|
||||
* They coordinate between services, blockers, and the UI.
|
||||
*/
|
||||
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
import type { AuthContextValue } from '@/lib/auth/AuthContext';
|
||||
import { AuthorizationBlocker } from '@/lib/blockers/AuthorizationBlocker';
|
||||
|
||||
export interface AuthGatewayConfig {
|
||||
/** Required roles for access (empty array = any authenticated user) */
|
||||
requiredRoles?: string[];
|
||||
/** Whether to redirect if unauthorized */
|
||||
redirectOnUnauthorized?: boolean;
|
||||
/** Redirect path if unauthorized */
|
||||
unauthorizedRedirectPath?: string;
|
||||
}
|
||||
|
||||
export class AuthGateway {
|
||||
private blocker: AuthorizationBlocker;
|
||||
private config: Required<AuthGatewayConfig>;
|
||||
|
||||
constructor(
|
||||
private authContext: AuthContextValue,
|
||||
config: AuthGatewayConfig = {}
|
||||
) {
|
||||
this.config = {
|
||||
requiredRoles: config.requiredRoles || [],
|
||||
redirectOnUnauthorized: config.redirectOnUnauthorized ?? true,
|
||||
unauthorizedRedirectPath: config.unauthorizedRedirectPath || '/auth/login',
|
||||
};
|
||||
|
||||
this.blocker = new AuthorizationBlocker(this.config.requiredRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user has access
|
||||
*/
|
||||
canAccess(): boolean {
|
||||
// Update blocker with current session
|
||||
this.blocker.updateSession(this.authContext.session);
|
||||
|
||||
return this.blocker.canExecute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current access state
|
||||
*/
|
||||
getAccessState(): {
|
||||
canAccess: boolean;
|
||||
reason: string;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
} {
|
||||
const reason = this.blocker.getReason();
|
||||
|
||||
return {
|
||||
canAccess: this.canAccess(),
|
||||
reason: this.blocker.getBlockMessage(),
|
||||
// Only show loading if auth context is still loading
|
||||
// If auth context is done but session is null, that's unauthenticated (not loading)
|
||||
isLoading: this.authContext.loading,
|
||||
isAuthenticated: this.authContext.session?.isAuthenticated ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce access control - throws if access denied
|
||||
* Used for programmatic access control
|
||||
*/
|
||||
enforceAccess(): void {
|
||||
if (!this.canAccess()) {
|
||||
const reason = this.blocker.getBlockMessage();
|
||||
throw new Error(`Access denied: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to unauthorized page if needed
|
||||
* Returns true if redirect was performed
|
||||
*/
|
||||
redirectIfUnauthorized(): boolean {
|
||||
if (this.canAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.config.redirectOnUnauthorized) {
|
||||
// Note: We can't use router here since this is a pure class
|
||||
// The component using this gateway should handle the redirect
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect path for unauthorized access
|
||||
*/
|
||||
getUnauthorizedRedirectPath(): string {
|
||||
return this.config.unauthorizedRedirectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the gateway state (e.g., after login/logout)
|
||||
*/
|
||||
refresh(): void {
|
||||
this.blocker.updateSession(this.authContext.session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is loading
|
||||
*/
|
||||
isLoading(): boolean {
|
||||
return this.authContext.loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.authContext.session?.isAuthenticated ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
*/
|
||||
getSession(): SessionViewModel | null {
|
||||
return this.authContext.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block reason for debugging
|
||||
*/
|
||||
getBlockReason(): string {
|
||||
return this.blocker.getReason();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly block message
|
||||
*/
|
||||
getBlockMessage(): string {
|
||||
return this.blocker.getBlockMessage();
|
||||
}
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
/**
|
||||
* TDD Tests for AuthGuard Component
|
||||
*
|
||||
* Tests authentication protection for React components
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AuthGuard, useAuthAccess } from './AuthGuard';
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
describe('Component Structure', () => {
|
||||
it('should export AuthGuard component', () => {
|
||||
expect(typeof AuthGuard).toBe('function');
|
||||
});
|
||||
|
||||
it('should export useAuthAccess hook', () => {
|
||||
expect(typeof useAuthAccess).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Configuration', () => {
|
||||
it('should use /auth/login as default redirect path', () => {
|
||||
// The component should default to /auth/login when not authenticated
|
||||
// This is verified by the default parameter in the component
|
||||
const defaultProps = {
|
||||
redirectPath: '/auth/login',
|
||||
};
|
||||
expect(defaultProps.redirectPath).toBe('/auth/login');
|
||||
});
|
||||
|
||||
it('should accept custom redirect path', () => {
|
||||
const customProps = {
|
||||
redirectPath: '/custom-login',
|
||||
};
|
||||
expect(customProps.redirectPath).toBe('/custom-login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication Requirements', () => {
|
||||
it('should require authentication for any authenticated user', () => {
|
||||
// AuthGuard uses empty requiredRoles array, meaning any authenticated user
|
||||
const config = {
|
||||
requiredRoles: [],
|
||||
};
|
||||
expect(config.requiredRoles).toEqual([]);
|
||||
expect(config.requiredRoles.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should redirect on unauthorized access', () => {
|
||||
const config = {
|
||||
redirectOnUnauthorized: true,
|
||||
unauthorizedRedirectPath: '/auth/login',
|
||||
};
|
||||
expect(config.redirectOnUnauthorized).toBe(true);
|
||||
expect(config.unauthorizedRedirectPath).toBe('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Props', () => {
|
||||
it('should accept children prop', () => {
|
||||
const props = {
|
||||
children: 'mock-children',
|
||||
};
|
||||
expect(props.children).toBe('mock-children');
|
||||
});
|
||||
|
||||
it('should accept optional loadingComponent', () => {
|
||||
const props = {
|
||||
children: 'mock-children',
|
||||
loadingComponent: 'loading...',
|
||||
};
|
||||
expect(props.loadingComponent).toBe('loading...');
|
||||
});
|
||||
|
||||
it('should accept optional unauthorizedComponent', () => {
|
||||
const props = {
|
||||
children: 'mock-children',
|
||||
unauthorizedComponent: 'unauthorized',
|
||||
};
|
||||
expect(props.unauthorizedComponent).toBe('unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with RouteGuard', () => {
|
||||
it('should pass correct config to RouteGuard', () => {
|
||||
const expectedConfig = {
|
||||
requiredRoles: [],
|
||||
redirectOnUnauthorized: true,
|
||||
unauthorizedRedirectPath: '/auth/login',
|
||||
};
|
||||
|
||||
expect(expectedConfig.requiredRoles).toEqual([]);
|
||||
expect(expectedConfig.redirectOnUnauthorized).toBe(true);
|
||||
expect(expectedConfig.unauthorizedRedirectPath).toBe('/auth/login');
|
||||
});
|
||||
|
||||
it('should support custom redirect paths', () => {
|
||||
const customPath = '/dashboard';
|
||||
const config = {
|
||||
requiredRoles: [],
|
||||
redirectOnUnauthorized: true,
|
||||
unauthorizedRedirectPath: customPath,
|
||||
};
|
||||
|
||||
expect(config.unauthorizedRedirectPath).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Functionality', () => {
|
||||
it('should export useRouteGuard as useAuthAccess', () => {
|
||||
// This verifies the hook export is correct
|
||||
expect(typeof useAuthAccess).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide authentication status', () => {
|
||||
// The hook should return authentication status
|
||||
// This is a structural test - actual implementation tested in RouteGuard
|
||||
expect(useAuthAccess).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Requirements', () => {
|
||||
it('should protect routes from unauthenticated access', () => {
|
||||
const securityConfig = {
|
||||
requiresAuth: true,
|
||||
redirectIfUnauthenticated: true,
|
||||
redirectPath: '/auth/login',
|
||||
};
|
||||
|
||||
expect(securityConfig.requiresAuth).toBe(true);
|
||||
expect(securityConfig.redirectIfUnauthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('should not require specific roles', () => {
|
||||
// AuthGuard is for any authenticated user, not role-specific
|
||||
const config = {
|
||||
requiredRoles: [],
|
||||
};
|
||||
|
||||
expect(config.requiredRoles.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty children', () => {
|
||||
const props = {
|
||||
children: null,
|
||||
};
|
||||
expect(props.children).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined optional props', () => {
|
||||
const props = {
|
||||
children: 'content',
|
||||
loadingComponent: undefined,
|
||||
unauthorizedComponent: undefined,
|
||||
};
|
||||
expect(props.loadingComponent).toBeUndefined();
|
||||
expect(props.unauthorizedComponent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should support multiple redirect paths', () => {
|
||||
const paths = ['/auth/login', '/auth/signup', '/login'];
|
||||
paths.forEach(path => {
|
||||
expect(typeof path).toBe('string');
|
||||
expect(path.startsWith('/')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Usage Patterns', () => {
|
||||
it('should support nested children', () => {
|
||||
const nestedStructure = {
|
||||
parent: {
|
||||
child: {
|
||||
grandchild: 'content',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(nestedStructure.parent.child.grandchild).toBe('content');
|
||||
});
|
||||
|
||||
it('should work with conditional rendering', () => {
|
||||
const scenarios = [
|
||||
{ authenticated: true, showContent: true },
|
||||
{ authenticated: false, showContent: false },
|
||||
];
|
||||
|
||||
scenarios.forEach(scenario => {
|
||||
expect(typeof scenario.authenticated).toBe('boolean');
|
||||
expect(typeof scenario.showContent).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Considerations', () => {
|
||||
it('should not cause infinite re-renders', () => {
|
||||
// Component should be stable
|
||||
const renderCount = 1;
|
||||
expect(renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle rapid authentication state changes', () => {
|
||||
const states = [
|
||||
{ loading: true, authenticated: false },
|
||||
{ loading: false, authenticated: true },
|
||||
{ loading: false, authenticated: false },
|
||||
];
|
||||
|
||||
states.forEach(state => {
|
||||
expect(typeof state.loading).toBe('boolean');
|
||||
expect(typeof state.authenticated).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle missing redirect path gracefully', () => {
|
||||
const props = {
|
||||
children: 'content',
|
||||
// redirectPath uses default
|
||||
};
|
||||
|
||||
expect(props.children).toBe('content');
|
||||
// Default is applied in component definition
|
||||
});
|
||||
|
||||
it('should handle invalid redirect paths', () => {
|
||||
const invalidPaths = ['', null, undefined];
|
||||
invalidPaths.forEach(path => {
|
||||
// Component should handle these gracefully
|
||||
if (path !== null && path !== undefined) {
|
||||
expect(typeof path).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser Compatibility', () => {
|
||||
it('should work in client-side rendering', () => {
|
||||
// Uses 'use client' directive
|
||||
const isClientComponent = true;
|
||||
expect(isClientComponent).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle window navigation', () => {
|
||||
// Should support navigation to redirect paths
|
||||
const redirectPath = '/auth/login';
|
||||
expect(redirectPath.startsWith('/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should support screen readers', () => {
|
||||
// Component should be accessible
|
||||
const accessible = true;
|
||||
expect(accessible).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation', () => {
|
||||
// Should work with keyboard-only users
|
||||
const keyboardFriendly = true;
|
||||
expect(keyboardFriendly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Safety', () => {
|
||||
it('should have correct TypeScript types', () => {
|
||||
const props = {
|
||||
children: 'mock-children',
|
||||
redirectPath: '/auth/login',
|
||||
loadingComponent: 'loading',
|
||||
unauthorizedComponent: 'unauthorized',
|
||||
};
|
||||
|
||||
expect(props.children).toBeDefined();
|
||||
expect(props.redirectPath).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate prop types', () => {
|
||||
const validProps = {
|
||||
children: 'content',
|
||||
redirectPath: '/path',
|
||||
};
|
||||
|
||||
expect(typeof validProps.children).toBe('string');
|
||||
expect(typeof validProps.redirectPath).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard Integration Tests', () => {
|
||||
describe('Complete Authentication Flow', () => {
|
||||
it('should protect dashboard from unauthenticated users', () => {
|
||||
const flow = {
|
||||
unauthenticated: {
|
||||
visits: '/dashboard',
|
||||
action: 'redirect',
|
||||
destination: '/auth/login',
|
||||
},
|
||||
};
|
||||
|
||||
expect(flow.unauthenticated.action).toBe('redirect');
|
||||
expect(flow.unauthenticated.destination).toBe('/auth/login');
|
||||
});
|
||||
|
||||
it('should allow authenticated users to access protected content', () => {
|
||||
const flow = {
|
||||
authenticated: {
|
||||
visits: '/dashboard',
|
||||
action: 'show',
|
||||
content: 'dashboard-content',
|
||||
},
|
||||
};
|
||||
|
||||
expect(flow.authenticated.action).toBe('show');
|
||||
expect(flow.authenticated.content).toBe('dashboard-content');
|
||||
});
|
||||
|
||||
it('should redirect authenticated users from auth pages', () => {
|
||||
const flow = {
|
||||
authenticated: {
|
||||
visits: '/auth/login',
|
||||
action: 'redirect',
|
||||
destination: '/dashboard',
|
||||
},
|
||||
};
|
||||
|
||||
expect(flow.authenticated.action).toBe('redirect');
|
||||
expect(flow.authenticated.destination).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should handle session expiration', () => {
|
||||
const session = {
|
||||
active: true,
|
||||
expired: false,
|
||||
redirectOnExpiry: '/auth/login',
|
||||
};
|
||||
|
||||
expect(session.redirectOnExpiry).toBe('/auth/login');
|
||||
});
|
||||
|
||||
it('should handle remember me sessions', () => {
|
||||
const session = {
|
||||
type: 'remember-me',
|
||||
duration: '30 days',
|
||||
redirectPath: '/dashboard',
|
||||
};
|
||||
|
||||
expect(session.duration).toBe('30 days');
|
||||
expect(session.redirectPath).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role-Based Access (Future)', () => {
|
||||
it('should support role-based restrictions', () => {
|
||||
const config = {
|
||||
requiredRoles: ['admin', 'moderator'],
|
||||
};
|
||||
|
||||
expect(config.requiredRoles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle multiple role requirements', () => {
|
||||
const roles = ['user', 'admin', 'moderator'];
|
||||
expect(roles.length).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard Security Tests', () => {
|
||||
describe('Cross-Site Request Forgery Protection', () => {
|
||||
it('should validate redirect paths', () => {
|
||||
const safePaths = ['/dashboard', '/auth/login', '/profile'];
|
||||
safePaths.forEach(path => {
|
||||
expect(path.startsWith('/')).toBe(true);
|
||||
expect(path.includes('://')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent open redirects', () => {
|
||||
const maliciousPaths = [
|
||||
'https://evil.com',
|
||||
'//evil.com',
|
||||
'/evil.com',
|
||||
];
|
||||
|
||||
maliciousPaths.forEach(path => {
|
||||
const isSafe = !path.includes('://') && !path.startsWith('//') && path.startsWith('/');
|
||||
// Only /evil.com is considered safe (relative path)
|
||||
// https://evil.com and //evil.com are unsafe
|
||||
if (path === '/evil.com') {
|
||||
expect(isSafe).toBe(true);
|
||||
} else {
|
||||
expect(isSafe).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication State Security', () => {
|
||||
it('should verify authentication before allowing access', () => {
|
||||
const securityCheck = {
|
||||
requiresVerification: true,
|
||||
checkBeforeRedirect: true,
|
||||
};
|
||||
|
||||
expect(securityCheck.requiresVerification).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle token validation', () => {
|
||||
const tokenValidation = {
|
||||
required: true,
|
||||
validateOnMount: true,
|
||||
redirectIfInvalid: '/auth/login',
|
||||
};
|
||||
|
||||
expect(tokenValidation.redirectIfInvalid).toBe('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Protection', () => {
|
||||
it('should not expose sensitive data in URL', () => {
|
||||
const safeUrl = '/dashboard';
|
||||
const unsafeUrl = '/dashboard?token=secret';
|
||||
|
||||
expect(safeUrl).not.toContain('token');
|
||||
expect(unsafeUrl).toContain('token');
|
||||
});
|
||||
|
||||
it('should use secure cookies', () => {
|
||||
const cookieConfig = {
|
||||
name: 'gp_session',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
};
|
||||
|
||||
expect(cookieConfig.secure).toBe(true);
|
||||
expect(cookieConfig.httpOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard Performance Tests', () => {
|
||||
describe('Rendering Performance', () => {
|
||||
it('should render quickly', () => {
|
||||
const renderTime = 50; // ms
|
||||
expect(renderTime).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should minimize re-renders', () => {
|
||||
const reRenderCount = 0;
|
||||
expect(reRenderCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Management', () => {
|
||||
it('should clean up event listeners', () => {
|
||||
const cleanup = {
|
||||
listeners: 0,
|
||||
afterUnmount: 0,
|
||||
};
|
||||
|
||||
expect(cleanup.listeners).toBe(cleanup.afterUnmount);
|
||||
});
|
||||
|
||||
it('should handle large component trees', () => {
|
||||
const treeSize = {
|
||||
depth: 5,
|
||||
branches: 10,
|
||||
totalNodes: 15625, // 10^5
|
||||
};
|
||||
|
||||
expect(treeSize.totalNodes).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard Edge Cases', () => {
|
||||
describe('Network Issues', () => {
|
||||
it('should handle offline mode', () => {
|
||||
const networkState = {
|
||||
online: false,
|
||||
fallback: 'cached',
|
||||
};
|
||||
|
||||
expect(networkState.online).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle slow connections', () => {
|
||||
const connection = {
|
||||
speed: 'slow',
|
||||
timeout: 5000,
|
||||
showLoading: true,
|
||||
};
|
||||
|
||||
expect(connection.showLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser State', () => {
|
||||
it('should handle tab switching', () => {
|
||||
const tabState = {
|
||||
active: true,
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
|
||||
expect(tabState.active).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle page refresh', () => {
|
||||
const refreshState = {
|
||||
preserved: true,
|
||||
sessionRestored: true,
|
||||
};
|
||||
|
||||
expect(refreshState.preserved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Actions', () => {
|
||||
it('should handle logout during protected view', () => {
|
||||
const logoutScenario = {
|
||||
state: 'protected',
|
||||
action: 'logout',
|
||||
result: 'redirect',
|
||||
destination: '/auth/login',
|
||||
};
|
||||
|
||||
expect(logoutScenario.result).toBe('redirect');
|
||||
});
|
||||
|
||||
it('should handle login during auth page view', () => {
|
||||
const loginScenario = {
|
||||
state: '/auth/login',
|
||||
action: 'login',
|
||||
result: 'redirect',
|
||||
destination: '/dashboard',
|
||||
};
|
||||
|
||||
expect(loginScenario.result).toBe('redirect');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard Compliance Tests', () => {
|
||||
describe('GDPR Compliance', () => {
|
||||
it('should handle consent requirements', () => {
|
||||
const consent = {
|
||||
required: true,
|
||||
beforeAuth: true,
|
||||
storage: 'cookies',
|
||||
};
|
||||
|
||||
expect(consent.required).toBe(true);
|
||||
});
|
||||
|
||||
it('should provide data access', () => {
|
||||
const dataAccess = {
|
||||
canExport: true,
|
||||
canDelete: true,
|
||||
transparent: true,
|
||||
};
|
||||
|
||||
expect(dataAccess.canExport).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility Standards', () => {
|
||||
it('should meet WCAG 2.1 Level AA', () => {
|
||||
const standards = {
|
||||
colorContrast: true,
|
||||
keyboardNav: true,
|
||||
screenReader: true,
|
||||
focusVisible: true,
|
||||
};
|
||||
|
||||
expect(standards.screenReader).toBe(true);
|
||||
});
|
||||
|
||||
it('should support reduced motion', () => {
|
||||
const motion = {
|
||||
respectPreference: true,
|
||||
fallback: 'instant',
|
||||
};
|
||||
|
||||
expect(motion.respectPreference).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Standards', () => {
|
||||
it('should prevent XSS attacks', () => {
|
||||
const xssProtection = {
|
||||
inputValidation: true,
|
||||
outputEncoding: true,
|
||||
csp: true,
|
||||
};
|
||||
|
||||
expect(xssProtection.csp).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent CSRF attacks', () => {
|
||||
const csrfProtection = {
|
||||
tokenValidation: true,
|
||||
originCheck: true,
|
||||
sameSite: true,
|
||||
};
|
||||
|
||||
expect(csrfProtection.sameSite).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard Final Validation', () => {
|
||||
it('should meet all user requirements', () => {
|
||||
const requirements = {
|
||||
loginForwarding: true,
|
||||
authPageProtection: true,
|
||||
rememberMe: true,
|
||||
security: true,
|
||||
performance: true,
|
||||
accessibility: true,
|
||||
};
|
||||
|
||||
Object.values(requirements).forEach(value => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be production-ready', () => {
|
||||
const productionReady = {
|
||||
tested: true,
|
||||
documented: true,
|
||||
secure: true,
|
||||
performant: true,
|
||||
accessible: true,
|
||||
};
|
||||
|
||||
expect(productionReady.tested).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AuthGuard } from './AuthGuard';
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('should be defined', () => {
|
||||
expect(AuthGuard).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Component: AuthGuard
|
||||
*
|
||||
* Protects routes that require authentication but not specific roles.
|
||||
* Uses the same Gateway pattern for consistency.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { RouteGuard } from './RouteGuard';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Path to redirect to if not authenticated
|
||||
*/
|
||||
redirectPath?: string;
|
||||
/**
|
||||
* Custom loading component (optional)
|
||||
*/
|
||||
loadingComponent?: ReactNode;
|
||||
/**
|
||||
* Custom unauthorized component (optional)
|
||||
*/
|
||||
unauthorizedComponent?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthGuard Component
|
||||
*
|
||||
* Protects child components requiring authentication.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <AuthGuard>
|
||||
* <ProtectedPage />
|
||||
* </AuthGuard>
|
||||
* ```
|
||||
*/
|
||||
export function AuthGuard({
|
||||
children,
|
||||
redirectPath = '/auth/login',
|
||||
loadingComponent,
|
||||
unauthorizedComponent,
|
||||
}: AuthGuardProps) {
|
||||
return (
|
||||
<RouteGuard
|
||||
config={{
|
||||
requiredRoles: [], // Any authenticated user
|
||||
redirectOnUnauthorized: true,
|
||||
unauthorizedRedirectPath: redirectPath,
|
||||
}}
|
||||
loadingComponent={loadingComponent}
|
||||
unauthorizedComponent={unauthorizedComponent}
|
||||
>
|
||||
{children}
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuth Hook
|
||||
*
|
||||
* Simplified hook for checking authentication status.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { isAuthenticated, loading } = useAuth();
|
||||
* ```
|
||||
*/
|
||||
export { useRouteGuard as useAuthAccess } from './RouteGuard';
|
||||
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* TDD Tests for RouteGuard Component
|
||||
*
|
||||
* These tests verify the RouteGuard component logic following TDD principles.
|
||||
* Note: These are integration tests that verify the component behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { RouteGuard } from './RouteGuard';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { AuthContextValue } from '@/lib/auth/AuthContext';
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/auth/AuthContext');
|
||||
vi.mock('next/navigation');
|
||||
|
||||
// Mock SessionViewModel factory
|
||||
function createMockSession(overrides: Partial<SessionViewModel> = {}): SessionViewModel {
|
||||
const baseSession = {
|
||||
isAuthenticated: true,
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: undefined,
|
||||
};
|
||||
|
||||
// Handle the case where overrides might have a user object
|
||||
// (for backward compatibility with existing test patterns)
|
||||
if (overrides.user) {
|
||||
const { user, ...rest } = overrides;
|
||||
return {
|
||||
...baseSession,
|
||||
...rest,
|
||||
userId: user.userId || baseSession.userId,
|
||||
email: user.email || baseSession.email,
|
||||
displayName: user.displayName || baseSession.displayName,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseSession,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock AuthContext factory
|
||||
function createMockAuthContext(overrides: Partial<AuthContextValue> = {}): AuthContextValue {
|
||||
return {
|
||||
session: null,
|
||||
loading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RouteGuard', () => {
|
||||
const mockUseAuth = vi.mocked(useAuth);
|
||||
const mockUseRouter = vi.mocked(useRouter);
|
||||
|
||||
let mockRouter: { push: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouter = { push: vi.fn() };
|
||||
mockUseRouter.mockReturnValue(mockRouter as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Authentication State', () => {
|
||||
it('should render children when user is authenticated', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: createMockSession(),
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state when auth context is loading', () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: true,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
// Should show loading state, not children
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should redirect when user is not authenticated', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Configuration', () => {
|
||||
it('should use custom redirect path when specified', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard config={{ unauthorizedRedirectPath: '/custom-login' }}>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/custom-login');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not redirect when redirectOnUnauthorized is false', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard config={{ redirectOnUnauthorized: false }}>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
// Wait for any potential redirects
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show unauthorized component when redirect is disabled', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
const unauthorizedComponent = <div data-testid="unauthorized">Access Denied</div>;
|
||||
|
||||
render(
|
||||
<RouteGuard
|
||||
config={{ redirectOnUnauthorized: false }}
|
||||
unauthorizedComponent={unauthorizedComponent}
|
||||
>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('unauthorized')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Loading Component', () => {
|
||||
it('should show custom loading component when specified', () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: true,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
const loadingComponent = <div data-testid="custom-loading">Custom Loading...</div>;
|
||||
|
||||
render(
|
||||
<RouteGuard loadingComponent={loadingComponent}>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role-Based Access', () => {
|
||||
it('should allow access when user has required role', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: createMockSession({
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: 'admin',
|
||||
},
|
||||
}),
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard config={{ requiredRoles: ['admin'] }}>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect when user lacks required role', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: createMockSession({
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'user@example.com',
|
||||
displayName: 'Regular User',
|
||||
role: 'user',
|
||||
},
|
||||
}),
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard config={{ requiredRoles: ['admin'] }}>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined session gracefully', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: undefined as any,
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty required roles array', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: createMockSession(),
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard config={{ requiredRoles: [] }}>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle rapid session state changes', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: true,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
const { rerender } = render(
|
||||
<RouteGuard>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
// Simulate session becoming available
|
||||
mockAuthContext.session = createMockSession();
|
||||
mockAuthContext.loading = false;
|
||||
|
||||
rerender(
|
||||
<RouteGuard>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redirect Timing', () => {
|
||||
it('should wait before redirecting (500ms delay)', async () => {
|
||||
const mockAuthContext = createMockAuthContext({
|
||||
session: null,
|
||||
loading: false,
|
||||
});
|
||||
mockUseAuth.mockReturnValue(mockAuthContext);
|
||||
|
||||
render(
|
||||
<RouteGuard>
|
||||
<div data-testid="protected-content">Protected Content</div>
|
||||
</RouteGuard>
|
||||
);
|
||||
|
||||
// Should not redirect immediately
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for the delay
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
||||
}, { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* Component: RouteGuard
|
||||
*
|
||||
* Higher-order component that protects routes using Gateways and Blockers.
|
||||
* Follows clean architecture by separating concerns:
|
||||
* - Gateway handles access logic
|
||||
* - Blocker handles prevention logic
|
||||
* - Component handles UI rendering
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect, useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { AuthGateway, AuthGatewayConfig } from './AuthGateway';
|
||||
import { LoadingState } from '@/components/shared/LoadingState';
|
||||
|
||||
interface RouteGuardProps {
|
||||
children: ReactNode;
|
||||
config?: AuthGatewayConfig;
|
||||
/**
|
||||
* Custom loading component (optional)
|
||||
*/
|
||||
loadingComponent?: ReactNode;
|
||||
/**
|
||||
* Custom unauthorized component (optional)
|
||||
*/
|
||||
unauthorizedComponent?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* RouteGuard Component
|
||||
*
|
||||
* Protects child components based on authentication and authorization rules.
|
||||
* Uses Gateway pattern for access control.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
|
||||
* <AdminDashboard />
|
||||
* </RouteGuard>
|
||||
* ```
|
||||
*/
|
||||
export function RouteGuard({
|
||||
children,
|
||||
config = {},
|
||||
loadingComponent,
|
||||
unauthorizedComponent,
|
||||
}: RouteGuardProps) {
|
||||
const router = useRouter();
|
||||
const authContext = useAuth();
|
||||
const [gateway] = useState(() => new AuthGateway(authContext, config));
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
// Calculate access state
|
||||
const accessState = useMemo(() => {
|
||||
gateway.refresh();
|
||||
return {
|
||||
canAccess: gateway.canAccess(),
|
||||
reason: gateway.getBlockMessage(),
|
||||
redirectPath: gateway.getUnauthorizedRedirectPath(),
|
||||
};
|
||||
}, [authContext.session, authContext.loading, gateway]);
|
||||
|
||||
// Handle the loading state and redirects
|
||||
useEffect(() => {
|
||||
// If we're loading, stay in checking state
|
||||
if (authContext.loading) {
|
||||
setIsChecking(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Done loading, can exit checking state
|
||||
setIsChecking(false);
|
||||
|
||||
// If we can't access and should redirect, do it
|
||||
if (!accessState.canAccess && config.redirectOnUnauthorized !== false) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push(accessState.redirectPath);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [authContext.loading, accessState.canAccess, accessState.redirectPath, config.redirectOnUnauthorized, router]);
|
||||
|
||||
// Show loading state
|
||||
if (isChecking || authContext.loading) {
|
||||
return loadingComponent || (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingState message="Verifying authentication..." className="min-h-screen" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show unauthorized state (only if not redirecting)
|
||||
if (!accessState.canAccess && config.redirectOnUnauthorized === false) {
|
||||
return unauthorizedComponent || (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="bg-iron-gray p-8 rounded-lg border border-charcoal-outline max-w-md text-center">
|
||||
<h2 className="text-xl font-bold text-racing-red mb-4">Access Denied</h2>
|
||||
<p className="text-gray-300 mb-6">{accessState.reason}</p>
|
||||
<button
|
||||
onClick={() => router.push('/auth/login')}
|
||||
className="px-4 py-2 bg-primary-blue text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show redirecting state
|
||||
if (!accessState.canAccess && config.redirectOnUnauthorized !== false) {
|
||||
// Don't show a message, just redirect silently
|
||||
// The redirect happens in the useEffect above
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render protected content
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useRouteGuard Hook
|
||||
*
|
||||
* Hook for programmatic access control within components.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { canAccess, reason, isLoading } = useRouteGuard({ requiredRoles: ['admin'] });
|
||||
* ```
|
||||
*/
|
||||
export function useRouteGuard(config: AuthGatewayConfig = {}) {
|
||||
const authContext = useAuth();
|
||||
const [gateway] = useState(() => new AuthGateway(authContext, config));
|
||||
const [state, setState] = useState(gateway.getAccessState());
|
||||
|
||||
useEffect(() => {
|
||||
gateway.refresh();
|
||||
setState(gateway.getAccessState());
|
||||
}, [authContext.session, authContext.loading, gateway]);
|
||||
|
||||
return {
|
||||
canAccess: state.canAccess,
|
||||
reason: state.reason,
|
||||
isLoading: state.isLoading,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
enforceAccess: () => gateway.enforceAccess(),
|
||||
redirectIfUnauthorized: () => gateway.redirectIfUnauthorized(),
|
||||
};
|
||||
}
|
||||
150
apps/website/lib/gateways/SessionGateway.test.ts
Normal file
150
apps/website/lib/gateways/SessionGateway.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* SessionGateway tests
|
||||
*
|
||||
* TDD: All tests mock cookies() from 'next/headers' and global.fetch
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SessionGateway } from './SessionGateway';
|
||||
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
|
||||
|
||||
// Mock next/headers
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock global.fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('SessionGateway', () => {
|
||||
let gateway: SessionGateway;
|
||||
let mockCookies: ReturnType<typeof vi.mocked>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const nextHeaders = await import('next/headers');
|
||||
mockCookies = vi.mocked(nextHeaders.cookies);
|
||||
gateway = new SessionGateway();
|
||||
});
|
||||
|
||||
describe('getSession()', () => {
|
||||
it('should return null when no cookies are present', async () => {
|
||||
// Arrange
|
||||
mockCookies.mockReturnValue({
|
||||
toString: () => '',
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
const result = await gateway.getSession();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return session object when valid gp_session cookie exists', async () => {
|
||||
// Arrange
|
||||
const mockSession: AuthSessionDTO = {
|
||||
token: 'valid-token',
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: 'driver',
|
||||
},
|
||||
};
|
||||
|
||||
mockCookies.mockReturnValue({
|
||||
toString: () => 'gp_session=valid-token; other=value',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockSession,
|
||||
} as Response);
|
||||
|
||||
// Act
|
||||
const result = await gateway.getSession();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/auth/session', {
|
||||
headers: { cookie: 'gp_session=valid-token; other=value' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when session is invalid or expired', async () => {
|
||||
// Arrange
|
||||
mockCookies.mockReturnValue({
|
||||
toString: () => 'gp_session=expired-token',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
} as Response);
|
||||
|
||||
// Act
|
||||
const result = await gateway.getSession();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null on non-2xx response', async () => {
|
||||
// Arrange
|
||||
mockCookies.mockReturnValue({
|
||||
toString: () => 'gp_session=some-token',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
} as Response);
|
||||
|
||||
// Act
|
||||
const result = await gateway.getSession();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
// Arrange
|
||||
mockCookies.mockReturnValue({
|
||||
toString: () => 'gp_session=some-token',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
// Act
|
||||
const result = await gateway.getSession();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when fetch throws any error', async () => {
|
||||
// Arrange
|
||||
mockCookies.mockReturnValue({
|
||||
toString: () => 'gp_session=some-token',
|
||||
} as any);
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error('Connection timeout'));
|
||||
|
||||
// Act
|
||||
const result = await gateway.getSession();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
63
apps/website/lib/gateways/SessionGateway.ts
Normal file
63
apps/website/lib/gateways/SessionGateway.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* SessionGateway - Server-side session management
|
||||
*
|
||||
* Fetches session data from the API using server cookies.
|
||||
* Designed for 'use server' contexts.
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
|
||||
|
||||
/**
|
||||
* SessionGateway class for server-side session management
|
||||
*
|
||||
* Uses Next.js server cookies and fetches session from API
|
||||
* Returns null on any error or non-2xx response (no throws)
|
||||
*/
|
||||
export class SessionGateway {
|
||||
/**
|
||||
* Get current authentication session
|
||||
*
|
||||
* @returns Promise<AuthSessionDTO | null> - Session object or null if not authenticated/error
|
||||
*/
|
||||
async getSession(): Promise<AuthSessionDTO | null> {
|
||||
try {
|
||||
// Get cookies from the current request
|
||||
const cookieStore = await cookies();
|
||||
const cookieString = cookieStore.toString();
|
||||
|
||||
// If no cookies, return null immediately
|
||||
if (!cookieString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine API base URL
|
||||
// In Docker/test: use API_BASE_URL env var or direct API URL
|
||||
// In production: use relative path which will be rewritten
|
||||
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3101';
|
||||
const apiUrl = `${baseUrl}/auth/session`;
|
||||
|
||||
// Fetch session from API with cookies forwarded
|
||||
// Use credentials: 'include' to ensure cookies are sent
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
cookie: cookieString,
|
||||
},
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Return null for non-2xx responses
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse and return session data
|
||||
const session = await response.json();
|
||||
return session as AuthSessionDTO;
|
||||
} catch (error) {
|
||||
// Return null on any error (network, parsing, etc.)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('gateways index', () => {
|
||||
it('should export gateways', async () => {
|
||||
const module = await import('./index');
|
||||
expect(Object.keys(module).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Gateways - Component-based access control
|
||||
*
|
||||
* Follows clean architecture by separating concerns:
|
||||
* - Blockers: Prevent execution (frontend UX)
|
||||
* - Gateways: Orchestrate access control
|
||||
* - Guards: Enforce security (backend)
|
||||
*/
|
||||
|
||||
export { AuthGateway } from './AuthGateway';
|
||||
export type { AuthGatewayConfig } from './AuthGateway';
|
||||
export { RouteGuard, useRouteGuard } from './RouteGuard';
|
||||
export { AuthGuard, useAuthAccess } from './AuthGuard';
|
||||
Reference in New Issue
Block a user