diff --git a/services/__tests__/oauth.service.test.ts b/services/__tests__/oauth.service.test.ts new file mode 100644 index 0000000..83bcf83 --- /dev/null +++ b/services/__tests__/oauth.service.test.ts @@ -0,0 +1,626 @@ +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'); + }); + }); +});