import { OAuthService, googleAuth, githubAuth, handleGoogleCallback, handleGithubCallback, } from '../oauth'; import { authService } from '../auth/auth.service'; import { MockNavigationService } from '../navigation/navigation.interface'; // Mock dependencies jest.mock('../auth/auth.service', () => ({ authService: { loginWithOAuth: jest.fn(), }, })); // Mock crypto API const mockRandomUUID = jest.fn(); Object.defineProperty(global, 'crypto', { value: { randomUUID: mockRandomUUID, }, writable: true, }); // Mock localStorage const mockLocalStorage = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn(), }; Object.defineProperty(global, 'localStorage', { value: mockLocalStorage, writable: true, }); // Mock URL constructor const mockURL = jest.fn(); Object.defineProperty(global, 'URL', { value: mockURL, writable: true, }); describe('OAuth Service', () => { let mockNavigation: MockNavigationService; let oauthService: OAuthService; beforeEach(() => { jest.clearAllMocks(); mockRandomUUID.mockReturnValue('mock-uuid-12345'); mockLocalStorage.getItem.mockReturnValue(null); // Create mock navigation service mockNavigation = new MockNavigationService(); oauthService = new OAuthService(mockNavigation); // Reset URL mock mockURL.mockClear(); }); describe('googleAuth', () => { test('should generate state and redirect to Google OAuth URL', () => { // Mock URL constructor to return a mock object const mockUrlInstance = { toString: jest .fn() .mockReturnValue( 'https://accounts.google.com/o/oauth2/v2/auth?client_id=mock_google_client_id&response_type=code&scope=openid%20email%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345' ), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); oauthService.googleAuth(); expect(mockRandomUUID).toHaveBeenCalled(); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( 'oauth_state', 'mock-uuid-12345' ); expect(mockURL).toHaveBeenCalledWith( 'https://accounts.google.com/o/oauth2/v2/auth' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'client_id', 'mock_google_client_id' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'response_type', 'code' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'scope', 'openid email profile' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'redirect_uri', 'http://localhost:3000/auth/callback' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'state', 'mock-uuid-12345' ); expect(mockNavigation.getLastRedirect()).toBe( 'https://accounts.google.com/o/oauth2/v2/auth?client_id=mock_google_client_id&response_type=code&scope=openid%20email%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345' ); }); test('should generate unique state for each call', () => { const mockUrlInstance = { toString: jest.fn().mockReturnValue('https://mock-url.com'), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); // First call mockRandomUUID.mockReturnValueOnce('state-1'); oauthService.googleAuth(); // Second call mockRandomUUID.mockReturnValueOnce('state-2'); oauthService.googleAuth(); expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith( 1, 'oauth_state', 'state-1' ); expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith( 2, 'oauth_state', 'state-2' ); }); }); describe('githubAuth', () => { test('should generate state and redirect to GitHub OAuth URL', () => { // Mock URL constructor to return a mock object const mockUrlInstance = { toString: jest .fn() .mockReturnValue( 'https://github.com/login/oauth/authorize?client_id=mock_github_client_id&response_type=code&scope=user%3Aemail&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345' ), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); oauthService.githubAuth(); expect(mockRandomUUID).toHaveBeenCalled(); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( 'oauth_state', 'mock-uuid-12345' ); expect(mockURL).toHaveBeenCalledWith( 'https://github.com/login/oauth/authorize' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'client_id', 'mock_github_client_id' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'response_type', 'code' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'scope', 'user:email' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'redirect_uri', 'http://localhost:3000/auth/callback' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'state', 'mock-uuid-12345' ); expect(mockNavigation.getLastRedirect()).toBe( 'https://github.com/login/oauth/authorize?client_id=mock_github_client_id&response_type=code&scope=user%3Aemail&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345' ); }); test('should generate unique state for each call', () => { const mockUrlInstance = { toString: jest.fn().mockReturnValue('https://mock-url.com'), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); // First call mockRandomUUID.mockReturnValueOnce('state-1'); oauthService.githubAuth(); // Second call mockRandomUUID.mockReturnValueOnce('state-2'); oauthService.githubAuth(); expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith( 1, 'oauth_state', 'state-1' ); expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith( 2, 'oauth_state', 'state-2' ); }); }); describe('handleGoogleCallback', () => { beforeEach(() => { // Set up mock navigation with callback parameters mockNavigation.setSearchParams('?code=auth-code&state=stored-state'); }); test('should successfully handle Google OAuth callback', async () => { mockLocalStorage.getItem.mockReturnValue('stored-state'); // Mock the UUIDs that will be generated for token and user info mockRandomUUID .mockReturnValueOnce('mock-token-uuid') .mockReturnValueOnce('mock-user-uuid'); (authService.loginWithOAuth as jest.Mock).mockResolvedValue({ user: { id: '123', email: 'google@example.com' }, accessToken: 'oauth_google_token_123456789', refreshToken: 'oauth_google_refresh_123456789', }); const result = await oauthService.handleGoogleCallback(); expect(authService.loginWithOAuth).toHaveBeenCalledWith('google', { email: 'mock_google_user_mock-user-uuid@example.com', username: 'Mock Google User', }); expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('oauth_state'); expect(result).toEqual({ user: { id: '123', email: 'google@example.com' }, accessToken: 'oauth_google_token_123456789', refreshToken: 'oauth_google_refresh_123456789', }); }); test('should throw error when state does not match', async () => { mockLocalStorage.getItem.mockReturnValue('stored-state'); mockNavigation.setSearchParams('?code=auth-code&state=different-state'); await expect(oauthService.handleGoogleCallback()).rejects.toThrow( 'Invalid OAuth state' ); expect(authService.loginWithOAuth).not.toHaveBeenCalled(); }); test('should throw error when no stored state exists', async () => { mockLocalStorage.getItem.mockReturnValue(null); mockNavigation.setSearchParams('?code=auth-code&state=some-state'); await expect(oauthService.handleGoogleCallback()).rejects.toThrow( 'Invalid OAuth state' ); }); test('should handle missing code parameter gracefully', async () => { mockLocalStorage.getItem.mockReturnValue('stored-state'); mockNavigation.setSearchParams('?state=stored-state'); // Mock the UUIDs for token exchange mockRandomUUID .mockReturnValueOnce('mock-token-uuid') .mockReturnValueOnce('mock-user-uuid'); (authService.loginWithOAuth as jest.Mock).mockResolvedValue({ user: { id: '123', email: 'google@example.com' }, accessToken: 'oauth_google_token_123456789', refreshToken: 'oauth_google_refresh_123456789', }); // The OAuth service handles null code by passing it to mock functions const result = await oauthService.handleGoogleCallback(); expect(result).toBeDefined(); expect(authService.loginWithOAuth).toHaveBeenCalled(); }); }); describe('handleGithubCallback', () => { beforeEach(() => { // Set up mock navigation with callback parameters mockNavigation.setSearchParams('?code=auth-code&state=stored-state'); }); test('should successfully handle GitHub OAuth callback', async () => { mockLocalStorage.getItem.mockReturnValue('stored-state'); // Mock the UUIDs that will be generated for token and user info mockRandomUUID .mockReturnValueOnce('mock-token-uuid') .mockReturnValueOnce('mock-user-uuid'); (authService.loginWithOAuth as jest.Mock).mockResolvedValue({ user: { id: '456', email: 'github@example.com' }, accessToken: 'oauth_github_token_123456789', refreshToken: 'oauth_github_refresh_123456789', }); const result = await oauthService.handleGithubCallback(); expect(authService.loginWithOAuth).toHaveBeenCalledWith('github', { email: 'mock_github_user_mock-user-uuid@example.com', username: 'Mock Github User', }); expect(result).toEqual({ user: { id: '456', email: 'github@example.com' }, accessToken: 'oauth_github_token_123456789', refreshToken: 'oauth_github_refresh_123456789', }); }); test('should throw error when state validation fails', async () => { mockLocalStorage.getItem.mockReturnValue('stored-state'); mockNavigation.setSearchParams('?code=auth-code&state=wrong-state'); await expect(oauthService.handleGithubCallback()).rejects.toThrow( 'Invalid OAuth state' ); }); }); describe('state management', () => { test('should clear OAuth state after successful callback', async () => { mockLocalStorage.getItem.mockReturnValue('test-state'); mockNavigation.setSearchParams('?code=auth-code&state=test-state'); // Mock UUIDs for the mock functions mockRandomUUID .mockReturnValueOnce('mock-token-uuid') .mockReturnValueOnce('mock-user-uuid'); (authService.loginWithOAuth as jest.Mock).mockResolvedValue({ user: {}, accessToken: 'token', refreshToken: 'refresh', }); await oauthService.handleGoogleCallback(); expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('oauth_state'); }); test('should not clear state on validation failure', async () => { mockLocalStorage.getItem.mockReturnValue('stored-state'); mockNavigation.setSearchParams('?code=auth-code&state=wrong-state'); try { await oauthService.handleGoogleCallback(); } catch (_error) { // Expected to throw } expect(mockLocalStorage.removeItem).not.toHaveBeenCalled(); }); }); describe('mock token exchange', () => { test('should generate unique mock access tokens', async () => { mockLocalStorage.getItem.mockReturnValue('test-state'); // First callback mockNavigation.setSearchParams('?code=code1&state=test-state'); // Mock different UUID values for token generation mockRandomUUID .mockReturnValueOnce('token-1') .mockReturnValueOnce('user-1'); (authService.loginWithOAuth as jest.Mock).mockResolvedValueOnce({ user: {}, accessToken: 'oauth_google_token_1', refreshToken: 'oauth_google_refresh_1', }); const result1 = await oauthService.handleGoogleCallback(); // Second callback mockNavigation.setSearchParams('?code=code2&state=test-state'); mockLocalStorage.getItem.mockReturnValue('test-state'); mockRandomUUID .mockReturnValueOnce('token-2') .mockReturnValueOnce('user-2'); (authService.loginWithOAuth as jest.Mock).mockResolvedValueOnce({ user: {}, accessToken: 'oauth_google_token_2', refreshToken: 'oauth_google_refresh_2', }); const result2 = await oauthService.handleGoogleCallback(); expect(result1.accessToken).not.toBe(result2.accessToken); expect(result1.refreshToken).not.toBe(result2.refreshToken); }); }); describe('mock user info generation', () => { test('should generate provider-specific user info', async () => { mockLocalStorage.getItem.mockReturnValue('test-state'); // Test Google callback mockNavigation.setSearchParams('?code=google-code&state=test-state'); mockRandomUUID .mockReturnValueOnce('google-token') .mockReturnValueOnce('google-user'); (authService.loginWithOAuth as jest.Mock).mockResolvedValue({ user: { provider: 'google', email: 'google@example.com' }, accessToken: 'google-token', refreshToken: 'google-refresh', }); await oauthService.handleGoogleCallback(); // Check that the correct user info was passed to loginWithOAuth const googleCall = (authService.loginWithOAuth as jest.Mock).mock .calls[0]; expect(googleCall[0]).toBe('google'); expect(googleCall[1].username).toBe('Mock Google User'); expect(googleCall[1].email).toContain('mock_google_user_'); // Test GitHub callback mockNavigation.setSearchParams('?code=github-code&state=test-state'); mockLocalStorage.getItem.mockReturnValue('test-state'); mockRandomUUID .mockReturnValueOnce('github-token') .mockReturnValueOnce('github-user'); (authService.loginWithOAuth as jest.Mock).mockResolvedValue({ user: { provider: 'github', email: 'github@example.com' }, accessToken: 'github-token', refreshToken: 'github-refresh', }); await oauthService.handleGithubCallback(); // Check that the correct user info was passed to loginWithOAuth const githubCall = (authService.loginWithOAuth as jest.Mock).mock .calls[1]; expect(githubCall[0]).toBe('github'); expect(githubCall[1].username).toBe('Mock Github User'); expect(githubCall[1].email).toContain('mock_github_user_'); }); }); describe('URL construction', () => { test('should properly encode redirect URI in Google auth URL', () => { const mockUrlInstance = { toString: jest .fn() .mockReturnValue('https://accounts.google.com/o/oauth2/v2/auth'), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); oauthService.googleAuth(); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'redirect_uri', 'http://localhost:3000/auth/callback' ); }); test('should properly encode redirect URI in GitHub auth URL', () => { const mockUrlInstance = { toString: jest .fn() .mockReturnValue('https://github.com/login/oauth/authorize'), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); oauthService.githubAuth(); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'redirect_uri', 'http://localhost:3000/auth/callback' ); }); test('should include all required OAuth parameters for Google', () => { const mockUrlInstance = { toString: jest.fn(), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); oauthService.googleAuth(); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'client_id', 'mock_google_client_id' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'response_type', 'code' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'scope', 'openid email profile' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'redirect_uri', 'http://localhost:3000/auth/callback' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'state', 'mock-uuid-12345' ); }); test('should include all required OAuth parameters for GitHub', () => { const mockUrlInstance = { toString: jest.fn(), searchParams: { append: jest.fn(), }, }; mockURL.mockReturnValue(mockUrlInstance); oauthService.githubAuth(); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'client_id', 'mock_github_client_id' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'response_type', 'code' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'scope', 'user:email' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'redirect_uri', 'http://localhost:3000/auth/callback' ); expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith( 'state', 'mock-uuid-12345' ); }); }); describe('backward compatibility', () => { test('should support legacy googleAuth function', () => { const mockUrlInstance = { toString: jest.fn().mockReturnValue('https://google-auth-url.com'), searchParams: { append: jest.fn() }, }; mockURL.mockReturnValue(mockUrlInstance); googleAuth(); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( 'oauth_state', 'mock-uuid-12345' ); }); test('should support legacy githubAuth function', () => { const mockUrlInstance = { toString: jest.fn().mockReturnValue('https://github-auth-url.com'), searchParams: { append: jest.fn() }, }; mockURL.mockReturnValue(mockUrlInstance); githubAuth(); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( 'oauth_state', 'mock-uuid-12345' ); }); test('should support legacy handleGoogleCallback function', async () => { mockLocalStorage.getItem.mockReturnValue('test-state'); // The legacy function uses browser navigation service which reads from window.location // We'll test this indirectly by ensuring it uses the same logic as the class method mockRandomUUID .mockReturnValueOnce('token-uuid') .mockReturnValueOnce('user-uuid'); (authService.loginWithOAuth as jest.Mock).mockResolvedValue({ user: { id: '123' }, accessToken: 'token', refreshToken: 'refresh', }); // Since we can't easily mock window.location in JSDOM, we'll verify // that the legacy functions exist and are callable expect(typeof handleGoogleCallback).toBe('function'); expect(typeof handleGithubCallback).toBe('function'); }); }); });