feat: implement navigation service for testable OAuth flows
- Add NavigationService interface with browser and mock implementations - Refactor OAuth service to use dependency injection for navigation - Enable comprehensive testing of OAuth flows by abstracting window.location - Maintain backward compatibility with existing OAuth functionality - Support both browser and test environments through interface abstraction This resolves the core OAuth testing issues caused by JSDOM window.location limitations and enables the 18 failing OAuth tests to pass.
This commit is contained in:
139
services/navigation/navigation.interface.ts
Normal file
139
services/navigation/navigation.interface.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Navigation Service Interface
|
||||
*
|
||||
* Provides an abstraction layer for browser navigation operations,
|
||||
* enabling better testability and dependency injection for OAuth flows.
|
||||
*/
|
||||
|
||||
export interface NavigationService {
|
||||
/**
|
||||
* Redirects the browser to the specified URL
|
||||
* @param url - The URL to navigate to
|
||||
*/
|
||||
redirectTo(url: string): void;
|
||||
|
||||
/**
|
||||
* Gets the current URL search parameters
|
||||
* @returns URLSearchParams object containing query parameters
|
||||
*/
|
||||
getSearchParams(): URLSearchParams;
|
||||
|
||||
/**
|
||||
* Gets the current origin (protocol + hostname + port)
|
||||
* @returns The current origin string
|
||||
*/
|
||||
getOrigin(): string;
|
||||
|
||||
/**
|
||||
* Gets the current pathname
|
||||
* @returns The current pathname string
|
||||
*/
|
||||
getPathname(): string;
|
||||
|
||||
/**
|
||||
* Replaces the current URL without triggering navigation
|
||||
* @param url - The new URL to replace with
|
||||
*/
|
||||
replaceUrl(url: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser Navigation Service Implementation
|
||||
*
|
||||
* Real implementation that uses the browser's window.location API
|
||||
*/
|
||||
export class BrowserNavigationService implements NavigationService {
|
||||
redirectTo(url: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
getSearchParams(): URLSearchParams {
|
||||
if (typeof window !== 'undefined') {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
return new URLSearchParams();
|
||||
}
|
||||
|
||||
getOrigin(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin;
|
||||
}
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
getPathname(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.pathname;
|
||||
}
|
||||
return '/';
|
||||
}
|
||||
|
||||
replaceUrl(url: string): void {
|
||||
if (typeof window !== 'undefined' && window.history) {
|
||||
window.history.replaceState(null, '', url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Navigation Service Implementation
|
||||
*
|
||||
* Test implementation that captures navigation calls for testing
|
||||
*/
|
||||
export class MockNavigationService implements NavigationService {
|
||||
public redirectCalls: string[] = [];
|
||||
public mockSearchParams: URLSearchParams;
|
||||
public mockOrigin: string;
|
||||
public mockPathname: string;
|
||||
public replaceCalls: string[] = [];
|
||||
|
||||
constructor(
|
||||
searchParams: string = '',
|
||||
origin: string = 'http://localhost:3000',
|
||||
pathname: string = '/'
|
||||
) {
|
||||
this.mockSearchParams = new URLSearchParams(searchParams);
|
||||
this.mockOrigin = origin;
|
||||
this.mockPathname = pathname;
|
||||
}
|
||||
|
||||
redirectTo(url: string): void {
|
||||
this.redirectCalls.push(url);
|
||||
}
|
||||
|
||||
getSearchParams(): URLSearchParams {
|
||||
return this.mockSearchParams;
|
||||
}
|
||||
|
||||
getOrigin(): string {
|
||||
return this.mockOrigin;
|
||||
}
|
||||
|
||||
getPathname(): string {
|
||||
return this.mockPathname;
|
||||
}
|
||||
|
||||
replaceUrl(url: string): void {
|
||||
this.replaceCalls.push(url);
|
||||
}
|
||||
|
||||
// Test utilities
|
||||
setSearchParams(searchParams: string): void {
|
||||
this.mockSearchParams = new URLSearchParams(searchParams);
|
||||
}
|
||||
|
||||
getLastRedirect(): string | undefined {
|
||||
return this.redirectCalls[this.redirectCalls.length - 1];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.redirectCalls = [];
|
||||
this.replaceCalls = [];
|
||||
this.mockSearchParams = new URLSearchParams();
|
||||
}
|
||||
}
|
||||
|
||||
// Default instance for production use
|
||||
export const navigationService = new BrowserNavigationService();
|
||||
@@ -1,4 +1,8 @@
|
||||
import { authService } from './auth/auth.service';
|
||||
import {
|
||||
NavigationService,
|
||||
navigationService,
|
||||
} from './navigation/navigation.interface';
|
||||
|
||||
// Mock OAuth configuration
|
||||
const GOOGLE_CLIENT_ID = 'mock_google_client_id';
|
||||
@@ -8,8 +12,6 @@ const GITHUB_CLIENT_ID = 'mock_github_client_id';
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
|
||||
|
||||
// Mock token exchange endpoints
|
||||
|
||||
// Mock OAuth scopes
|
||||
const GOOGLE_SCOPES = 'openid email profile';
|
||||
const GITHUB_SCOPES = 'user:email';
|
||||
@@ -20,115 +22,150 @@ const REDIRECT_URI = 'http://localhost:3000/auth/callback';
|
||||
// Mock OAuth state generation
|
||||
const generateState = () => crypto.randomUUID();
|
||||
|
||||
export const googleAuth = () => {
|
||||
const state = generateState();
|
||||
const url = new URL(GOOGLE_AUTH_URL);
|
||||
url.searchParams.append('client_id', GOOGLE_CLIENT_ID);
|
||||
url.searchParams.append('response_type', 'code');
|
||||
url.searchParams.append('scope', GOOGLE_SCOPES);
|
||||
url.searchParams.append('redirect_uri', REDIRECT_URI);
|
||||
url.searchParams.append('state', state);
|
||||
/**
|
||||
* OAuth Service Class
|
||||
*
|
||||
* Handles OAuth authentication flows with dependency injection for navigation,
|
||||
* making it more testable and maintainable.
|
||||
*/
|
||||
export class OAuthService {
|
||||
constructor(private navigation: NavigationService = navigationService) {}
|
||||
|
||||
// In a real implementation, we would store the state in the session or localStorage
|
||||
localStorage.setItem('oauth_state', state);
|
||||
/**
|
||||
* Initiates Google OAuth authentication flow
|
||||
*/
|
||||
googleAuth(): void {
|
||||
const state = generateState();
|
||||
const url = new URL(GOOGLE_AUTH_URL);
|
||||
url.searchParams.append('client_id', GOOGLE_CLIENT_ID);
|
||||
url.searchParams.append('response_type', 'code');
|
||||
url.searchParams.append('scope', GOOGLE_SCOPES);
|
||||
url.searchParams.append('redirect_uri', REDIRECT_URI);
|
||||
url.searchParams.append('state', state);
|
||||
|
||||
// Redirect to Google's auth endpoint
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
// Store state for CSRF protection
|
||||
localStorage.setItem('oauth_state', state);
|
||||
|
||||
export const githubAuth = () => {
|
||||
const state = generateState();
|
||||
const url = new URL(GITHUB_AUTH_URL);
|
||||
url.searchParams.append('client_id', GITHUB_CLIENT_ID);
|
||||
url.searchParams.append('response_type', 'code');
|
||||
url.searchParams.append('scope', GITHUB_SCOPES);
|
||||
url.searchParams.append('redirect_uri', REDIRECT_URI);
|
||||
url.searchParams.append('state', state);
|
||||
|
||||
// In a real implementation, we would store the state in the session or localStorage
|
||||
localStorage.setItem('oauth_state', state);
|
||||
|
||||
// Redirect to GitHub's auth endpoint
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
// Mock token exchange
|
||||
const mockExchangeCodeForToken = async (
|
||||
provider: 'google' | 'github',
|
||||
_code: string
|
||||
): Promise<string> => {
|
||||
// In a real implementation, we would make a POST request to the token endpoint
|
||||
// with the code, client_id, client_secret, and redirect_uri
|
||||
|
||||
// For this mock, we'll just return a mock access token
|
||||
return `mock_${provider}_access_token_${crypto.randomUUID()}`;
|
||||
};
|
||||
|
||||
// Mock user info retrieval
|
||||
const mockGetUserInfo = async (
|
||||
provider: 'google' | 'github',
|
||||
_accessToken: string
|
||||
): Promise<{ email: string; name: string }> => {
|
||||
// In a real implementation, we would make a GET request to the user info endpoint
|
||||
// with the access token
|
||||
|
||||
// For this mock, we'll return mock user info
|
||||
return {
|
||||
email: `mock_${provider}_user_${crypto.randomUUID()}@example.com`,
|
||||
name: `Mock ${provider.charAt(0).toUpperCase() + provider.slice(1)} User`,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleGoogleCallback = async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const storedState = localStorage.getItem('oauth_state');
|
||||
|
||||
// Verify state to prevent CSRF attacks
|
||||
if (state !== storedState) {
|
||||
throw new Error('Invalid OAuth state');
|
||||
// Redirect to Google's auth endpoint
|
||||
this.navigation.redirectTo(url.toString());
|
||||
}
|
||||
|
||||
// Clear stored state
|
||||
localStorage.removeItem('oauth_state');
|
||||
/**
|
||||
* Initiates GitHub OAuth authentication flow
|
||||
*/
|
||||
githubAuth(): void {
|
||||
const state = generateState();
|
||||
const url = new URL(GITHUB_AUTH_URL);
|
||||
url.searchParams.append('client_id', GITHUB_CLIENT_ID);
|
||||
url.searchParams.append('response_type', 'code');
|
||||
url.searchParams.append('scope', GITHUB_SCOPES);
|
||||
url.searchParams.append('redirect_uri', REDIRECT_URI);
|
||||
url.searchParams.append('state', state);
|
||||
|
||||
// Exchange code for token
|
||||
const accessToken = await mockExchangeCodeForToken('google', code);
|
||||
// Store state for CSRF protection
|
||||
localStorage.setItem('oauth_state', state);
|
||||
|
||||
// Get user info
|
||||
const userInfo = await mockGetUserInfo('google', accessToken);
|
||||
|
||||
// Register or login the user
|
||||
return authService.loginWithOAuth('google', {
|
||||
email: userInfo.email,
|
||||
username: userInfo.name,
|
||||
});
|
||||
};
|
||||
|
||||
export const handleGithubCallback = async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const storedState = localStorage.getItem('oauth_state');
|
||||
|
||||
// Verify state to prevent CSRF attacks
|
||||
if (state !== storedState) {
|
||||
throw new Error('Invalid OAuth state');
|
||||
// Redirect to GitHub's auth endpoint
|
||||
this.navigation.redirectTo(url.toString());
|
||||
}
|
||||
|
||||
// Clear stored state
|
||||
localStorage.removeItem('oauth_state');
|
||||
/**
|
||||
* Handles Google OAuth callback
|
||||
*/
|
||||
async handleGoogleCallback() {
|
||||
const params = this.navigation.getSearchParams();
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const storedState = localStorage.getItem('oauth_state');
|
||||
|
||||
// Exchange code for token
|
||||
const accessToken = await mockExchangeCodeForToken('github', code);
|
||||
// Verify state to prevent CSRF attacks
|
||||
if (state !== storedState) {
|
||||
throw new Error('Invalid OAuth state');
|
||||
}
|
||||
|
||||
// Get user info
|
||||
const userInfo = await mockGetUserInfo('github', accessToken);
|
||||
// Clear stored state
|
||||
localStorage.removeItem('oauth_state');
|
||||
|
||||
// Register or login the user
|
||||
return authService.loginWithOAuth('github', {
|
||||
email: userInfo.email,
|
||||
username: userInfo.name,
|
||||
});
|
||||
};
|
||||
// Exchange code for token
|
||||
const accessToken = await this.mockExchangeCodeForToken('google', code);
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.mockGetUserInfo('google', accessToken);
|
||||
|
||||
// Register or login the user
|
||||
return authService.loginWithOAuth('google', {
|
||||
email: userInfo.email,
|
||||
username: userInfo.name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles GitHub OAuth callback
|
||||
*/
|
||||
async handleGithubCallback() {
|
||||
const params = this.navigation.getSearchParams();
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const storedState = localStorage.getItem('oauth_state');
|
||||
|
||||
// Verify state to prevent CSRF attacks
|
||||
if (state !== storedState) {
|
||||
throw new Error('Invalid OAuth state');
|
||||
}
|
||||
|
||||
// Clear stored state
|
||||
localStorage.removeItem('oauth_state');
|
||||
|
||||
// Exchange code for token
|
||||
const accessToken = await this.mockExchangeCodeForToken('github', code);
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.mockGetUserInfo('github', accessToken);
|
||||
|
||||
// Register or login the user
|
||||
return authService.loginWithOAuth('github', {
|
||||
email: userInfo.email,
|
||||
username: userInfo.name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock token exchange
|
||||
* In a real implementation, this would make a POST request to the token endpoint
|
||||
*/
|
||||
private async mockExchangeCodeForToken(
|
||||
provider: 'google' | 'github',
|
||||
_code: string | null
|
||||
): Promise<string> {
|
||||
// For this mock, we'll just return a mock access token
|
||||
return `mock_${provider}_access_token_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock user info retrieval
|
||||
* In a real implementation, this would make a GET request to the user info endpoint
|
||||
*/
|
||||
private async mockGetUserInfo(
|
||||
provider: 'google' | 'github',
|
||||
_accessToken: string
|
||||
): Promise<{ email: string; name: string }> {
|
||||
// For this mock, we'll return mock user info
|
||||
return {
|
||||
email: `mock_${provider}_user_${crypto.randomUUID()}@example.com`,
|
||||
name: `Mock ${provider.charAt(0).toUpperCase() + provider.slice(1)} User`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default instance for production use
|
||||
const oauthService = new OAuthService();
|
||||
|
||||
// Export both the class and convenience functions for backward compatibility
|
||||
export const googleAuth = () => oauthService.googleAuth();
|
||||
export const githubAuth = () => oauthService.githubAuth();
|
||||
export const handleGoogleCallback = () => oauthService.handleGoogleCallback();
|
||||
export const handleGithubCallback = () => oauthService.handleGithubCallback();
|
||||
|
||||
// Export the service instance for direct use
|
||||
export { oauthService };
|
||||
export default oauthService;
|
||||
|
||||
Reference in New Issue
Block a user