import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OAuthController } from '../controllers/oauth.controller'; import { OAuthService, OAuthProfile, OAuthTokens, OAuthUrlResponse, OAuthConnectionResponse, } from '../services/oauth.service'; import { OAuthProvider } from '../entities/oauth-provider.enum'; import { RequestUser } from '../strategies/jwt.strategy'; describe('OAuthController', () => { let controller: OAuthController; let oauthService: jest.Mocked; const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; const mockUserId = '550e8400-e29b-41d4-a716-446655440000'; const mockRequestUser: RequestUser = { id: mockUserId, email: 'test@example.com', tenant_id: mockTenantId, }; const mockOAuthProfile: OAuthProfile = { id: 'oauth-provider-user-id-123', email: 'oauth-user@example.com', name: 'OAuth User', avatar_url: 'https://example.com/avatar.jpg', raw_data: { provider_specific: 'data' }, }; const mockOAuthTokens: OAuthTokens = { access_token: 'oauth_access_token_abc123', refresh_token: 'oauth_refresh_token_xyz789', expires_at: new Date(Date.now() + 3600000), // 1 hour from now scopes: ['email', 'profile'], }; const mockUser = { id: mockUserId, tenant_id: mockTenantId, email: 'oauth-user@example.com', first_name: 'OAuth', last_name: 'User', status: 'active', email_verified: true, }; const mockAuthResponse = { user: mockUser, accessToken: 'jwt_access_token', refreshToken: 'jwt_refresh_token', }; const mockRequest = { ip: '127.0.0.1', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'x-tenant-id': mockTenantId, }, }; const mockOAuthUrlResponse: OAuthUrlResponse = { url: 'https://accounts.google.com/o/oauth2/v2/auth?...', state: 'encoded_state_string', }; const mockOAuthConnection: OAuthConnectionResponse = { id: '550e8400-e29b-41d4-a716-446655440002', provider: OAuthProvider.GOOGLE, provider_email: 'oauth-user@example.com', provider_name: 'OAuth User', provider_avatar_url: 'https://example.com/avatar.jpg', created_at: new Date(), last_used_at: new Date(), }; beforeEach(async () => { const mockOAuthService = { getAuthorizationUrl: jest.fn(), handleOAuthLogin: jest.fn(), getConnections: jest.fn(), disconnectProvider: jest.fn(), linkProvider: jest.fn(), }; const mockConfigService = { get: jest.fn((key: string) => { const config: Record = { 'app.frontendUrl': 'http://localhost:3000', }; return config[key]; }), }; const module: TestingModule = await Test.createTestingModule({ controllers: [OAuthController], providers: [ { provide: OAuthService, useValue: mockOAuthService }, { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); controller = module.get(OAuthController); oauthService = module.get(OAuthService); }); afterEach(() => { jest.clearAllMocks(); }); // ==================== getAuthorizationUrl Tests ==================== describe('getAuthorizationUrl', () => { describe('success cases', () => { it('should return authorization URL for Google provider', () => { const googleUrl: OAuthUrlResponse = { url: 'https://accounts.google.com/o/oauth2/v2/auth?client_id=...', state: 'google_state_123', }; oauthService.getAuthorizationUrl.mockReturnValue(googleUrl); const result = controller.getAuthorizationUrl('google', mockTenantId); expect(result).toEqual(googleUrl); expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( OAuthProvider.GOOGLE, mockTenantId, ); }); it('should return authorization URL for Microsoft provider', () => { const microsoftUrl: OAuthUrlResponse = { url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...', state: 'microsoft_state_456', }; oauthService.getAuthorizationUrl.mockReturnValue(microsoftUrl); const result = controller.getAuthorizationUrl('microsoft', mockTenantId); expect(result).toEqual(microsoftUrl); expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( OAuthProvider.MICROSOFT, mockTenantId, ); }); it('should return authorization URL for GitHub provider', () => { const githubUrl: OAuthUrlResponse = { url: 'https://github.com/login/oauth/authorize?...', state: 'github_state_789', }; oauthService.getAuthorizationUrl.mockReturnValue(githubUrl); const result = controller.getAuthorizationUrl('github', mockTenantId); expect(result).toEqual(githubUrl); expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( OAuthProvider.GITHUB, mockTenantId, ); }); it('should return authorization URL for Apple provider', () => { const appleUrl: OAuthUrlResponse = { url: 'https://appleid.apple.com/auth/authorize?...', state: 'apple_state_abc', }; oauthService.getAuthorizationUrl.mockReturnValue(appleUrl); const result = controller.getAuthorizationUrl('apple', mockTenantId); expect(result).toEqual(appleUrl); expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( OAuthProvider.APPLE, mockTenantId, ); }); it('should handle uppercase provider names', () => { oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); const result = controller.getAuthorizationUrl('GOOGLE', mockTenantId); expect(result).toEqual(mockOAuthUrlResponse); expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( OAuthProvider.GOOGLE, mockTenantId, ); }); it('should handle mixed case provider names', () => { oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); const result = controller.getAuthorizationUrl('GoOgLe', mockTenantId); expect(result).toEqual(mockOAuthUrlResponse); expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( OAuthProvider.GOOGLE, mockTenantId, ); }); }); describe('error cases', () => { it('should throw BadRequestException when tenant ID is missing', () => { expect(() => controller.getAuthorizationUrl('google', undefined as any), ).toThrow(BadRequestException); expect(() => controller.getAuthorizationUrl('google', undefined as any), ).toThrow('Tenant ID es requerido'); }); it('should throw BadRequestException when tenant ID is empty string', () => { expect(() => controller.getAuthorizationUrl('google', '')).toThrow( BadRequestException, ); }); it('should throw BadRequestException for invalid provider', () => { expect(() => controller.getAuthorizationUrl('facebook', mockTenantId), ).toThrow(BadRequestException); expect(() => controller.getAuthorizationUrl('facebook', mockTenantId), ).toThrow(/Proveedor OAuth no vĂ¡lido: facebook/); }); it('should throw BadRequestException for unknown provider', () => { expect(() => controller.getAuthorizationUrl('linkedin', mockTenantId), ).toThrow(BadRequestException); }); it('should include supported providers in error message', () => { try { controller.getAuthorizationUrl('invalid-provider', mockTenantId); fail('Expected BadRequestException'); } catch (error) { expect(error.message).toContain('google'); expect(error.message).toContain('microsoft'); expect(error.message).toContain('github'); expect(error.message).toContain('apple'); } }); }); }); // ==================== handleCallback Tests ==================== describe('handleCallback', () => { describe('success cases', () => { it('should handle Google OAuth callback successfully', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const body = { code: 'authorization_code_123', state: 'state_token', profile: mockOAuthProfile, tokens: mockOAuthTokens, }; const result = await controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ); expect(result).toEqual(mockAuthResponse); expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( OAuthProvider.GOOGLE, mockOAuthProfile, mockOAuthTokens, mockTenantId, mockRequest, ); }); it('should handle Microsoft OAuth callback successfully', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const body = { code: 'ms_auth_code', profile: { ...mockOAuthProfile, email: 'user@outlook.com' }, tokens: mockOAuthTokens, }; const result = await controller.handleCallback( 'microsoft', body, mockTenantId, mockRequest as any, ); expect(result).toEqual(mockAuthResponse); expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( OAuthProvider.MICROSOFT, expect.objectContaining({ email: 'user@outlook.com' }), mockOAuthTokens, mockTenantId, mockRequest, ); }); it('should handle GitHub OAuth callback successfully', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const body = { code: 'github_code', profile: { ...mockOAuthProfile, email: 'dev@github.com' }, tokens: { ...mockOAuthTokens, refresh_token: undefined }, // GitHub doesn't return refresh token }; const result = await controller.handleCallback( 'github', body, mockTenantId, mockRequest as any, ); expect(result).toEqual(mockAuthResponse); expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( OAuthProvider.GITHUB, expect.objectContaining({ email: 'dev@github.com' }), expect.objectContaining({ refresh_token: undefined }), mockTenantId, mockRequest, ); }); it('should create new user when OAuth email does not exist', async () => { const newUserResponse = { user: { ...mockUser, id: 'new-user-id', email: 'newuser@oauth.com', }, accessToken: 'new_access_token', refreshToken: 'new_refresh_token', }; oauthService.handleOAuthLogin.mockResolvedValue(newUserResponse); const body = { code: 'new_user_code', profile: { ...mockOAuthProfile, email: 'newuser@oauth.com' }, tokens: mockOAuthTokens, }; const result = await controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ); expect(result.user.email).toBe('newuser@oauth.com'); expect(result).toHaveProperty('accessToken'); expect(result).toHaveProperty('refreshToken'); }); it('should link OAuth to existing user when email matches', async () => { const existingUserResponse = { user: mockUser, accessToken: 'linked_access_token', refreshToken: 'linked_refresh_token', }; oauthService.handleOAuthLogin.mockResolvedValue(existingUserResponse); const body = { code: 'existing_user_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }; const result = await controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ); expect(result.user.id).toBe(mockUserId); }); it('should handle callback with minimal profile data', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const minimalProfile: OAuthProfile = { id: 'min-oauth-id', email: 'minimal@example.com', }; const body = { code: 'minimal_code', profile: minimalProfile, tokens: mockOAuthTokens, }; const result = await controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ); expect(result).toEqual(mockAuthResponse); expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( OAuthProvider.GOOGLE, minimalProfile, mockOAuthTokens, mockTenantId, mockRequest, ); }); it('should handle callback with minimal token data', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const minimalTokens: OAuthTokens = { access_token: 'only_access_token', }; const body = { code: 'minimal_token_code', profile: mockOAuthProfile, tokens: minimalTokens, }; const result = await controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ); expect(result).toEqual(mockAuthResponse); }); }); describe('error cases', () => { it('should throw BadRequestException when tenant ID is missing', async () => { const body = { code: 'some_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }; await expect( controller.handleCallback( 'google', body, undefined as any, mockRequest as any, ), ).rejects.toThrow(BadRequestException); await expect( controller.handleCallback( 'google', body, undefined as any, mockRequest as any, ), ).rejects.toThrow('Tenant ID es requerido'); }); it('should throw BadRequestException when profile is missing', async () => { const body = { code: 'some_code', tokens: mockOAuthTokens, } as any; await expect( controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ), ).rejects.toThrow(BadRequestException); await expect( controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ), ).rejects.toThrow('Se requiere profile y tokens del proveedor OAuth'); }); it('should throw BadRequestException when tokens are missing', async () => { const body = { code: 'some_code', profile: mockOAuthProfile, } as any; await expect( controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ), ).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException when both profile and tokens are missing', async () => { const body = { code: 'some_code', } as any; await expect( controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ), ).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException for invalid provider', async () => { const body = { code: 'some_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }; await expect( controller.handleCallback( 'facebook', body, mockTenantId, mockRequest as any, ), ).rejects.toThrow(BadRequestException); }); it('should propagate service errors', async () => { const serviceError = new Error('OAuth service error'); oauthService.handleOAuthLogin.mockRejectedValue(serviceError); const body = { code: 'error_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }; await expect( controller.handleCallback( 'google', body, mockTenantId, mockRequest as any, ), ).rejects.toThrow('OAuth service error'); }); }); }); // ==================== getConnections Tests ==================== describe('getConnections', () => { describe('success cases', () => { it('should return list of OAuth connections for user', async () => { const connections: OAuthConnectionResponse[] = [ mockOAuthConnection, { ...mockOAuthConnection, id: '550e8400-e29b-41d4-a716-446655440003', provider: OAuthProvider.GITHUB, provider_email: 'dev@github.com', }, ]; oauthService.getConnections.mockResolvedValue(connections); const result = await controller.getConnections(mockRequestUser); expect(result).toEqual(connections); expect(result).toHaveLength(2); expect(oauthService.getConnections).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, ); }); it('should return empty array when user has no OAuth connections', async () => { oauthService.getConnections.mockResolvedValue([]); const result = await controller.getConnections(mockRequestUser); expect(result).toEqual([]); expect(result).toHaveLength(0); }); it('should return single connection when user has one OAuth provider', async () => { oauthService.getConnections.mockResolvedValue([mockOAuthConnection]); const result = await controller.getConnections(mockRequestUser); expect(result).toHaveLength(1); expect(result[0].provider).toBe(OAuthProvider.GOOGLE); }); it('should return connections with all providers', async () => { const allProviderConnections: OAuthConnectionResponse[] = [ { ...mockOAuthConnection, provider: OAuthProvider.GOOGLE }, { ...mockOAuthConnection, id: 'id-2', provider: OAuthProvider.MICROSOFT }, { ...mockOAuthConnection, id: 'id-3', provider: OAuthProvider.GITHUB }, { ...mockOAuthConnection, id: 'id-4', provider: OAuthProvider.APPLE }, ]; oauthService.getConnections.mockResolvedValue(allProviderConnections); const result = await controller.getConnections(mockRequestUser); expect(result).toHaveLength(4); expect(result.map((c) => c.provider)).toContain(OAuthProvider.GOOGLE); expect(result.map((c) => c.provider)).toContain(OAuthProvider.MICROSOFT); expect(result.map((c) => c.provider)).toContain(OAuthProvider.GITHUB); expect(result.map((c) => c.provider)).toContain(OAuthProvider.APPLE); }); }); describe('error cases', () => { it('should propagate service errors', async () => { oauthService.getConnections.mockRejectedValue( new Error('Database error'), ); await expect(controller.getConnections(mockRequestUser)).rejects.toThrow( 'Database error', ); }); }); }); // ==================== disconnectProvider Tests ==================== describe('disconnectProvider', () => { describe('success cases', () => { it('should disconnect Google provider successfully', async () => { const successMessage = { message: 'Proveedor google desconectado correctamente', }; oauthService.disconnectProvider.mockResolvedValue(successMessage); const result = await controller.disconnectProvider( 'google', mockRequestUser, ); expect(result).toEqual(successMessage); expect(oauthService.disconnectProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.GOOGLE, ); }); it('should disconnect Microsoft provider successfully', async () => { const successMessage = { message: 'Proveedor microsoft desconectado correctamente', }; oauthService.disconnectProvider.mockResolvedValue(successMessage); const result = await controller.disconnectProvider( 'microsoft', mockRequestUser, ); expect(result).toEqual(successMessage); expect(oauthService.disconnectProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.MICROSOFT, ); }); it('should disconnect GitHub provider successfully', async () => { const successMessage = { message: 'Proveedor github desconectado correctamente', }; oauthService.disconnectProvider.mockResolvedValue(successMessage); const result = await controller.disconnectProvider( 'github', mockRequestUser, ); expect(result).toEqual(successMessage); expect(oauthService.disconnectProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.GITHUB, ); }); it('should disconnect Apple provider successfully', async () => { const successMessage = { message: 'Proveedor apple desconectado correctamente', }; oauthService.disconnectProvider.mockResolvedValue(successMessage); const result = await controller.disconnectProvider( 'apple', mockRequestUser, ); expect(result).toEqual(successMessage); expect(oauthService.disconnectProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.APPLE, ); }); it('should handle uppercase provider names when disconnecting', async () => { const successMessage = { message: 'Provider disconnected' }; oauthService.disconnectProvider.mockResolvedValue(successMessage); await controller.disconnectProvider('GOOGLE', mockRequestUser); expect(oauthService.disconnectProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.GOOGLE, ); }); }); describe('error cases', () => { it('should throw BadRequestException for invalid provider', async () => { await expect( controller.disconnectProvider('facebook', mockRequestUser), ).rejects.toThrow(BadRequestException); }); it('should propagate NotFoundException when connection not found', async () => { const notFoundError = new Error('No existe conexion con google'); oauthService.disconnectProvider.mockRejectedValue(notFoundError); await expect( controller.disconnectProvider('google', mockRequestUser), ).rejects.toThrow('No existe conexion con google'); }); it('should propagate ConflictException when trying to disconnect only auth method', async () => { const conflictError = new Error( 'No puedes desconectar el unico metodo de autenticacion', ); oauthService.disconnectProvider.mockRejectedValue(conflictError); await expect( controller.disconnectProvider('google', mockRequestUser), ).rejects.toThrow('No puedes desconectar el unico metodo de autenticacion'); }); }); }); // ==================== linkProvider Tests ==================== describe('linkProvider', () => { describe('success cases', () => { it('should link Google provider to existing account', async () => { oauthService.linkProvider.mockResolvedValue(mockOAuthConnection); const body = { profile: mockOAuthProfile, tokens: mockOAuthTokens, }; const result = await controller.linkProvider( 'google', body, mockRequestUser, ); expect(result).toEqual(mockOAuthConnection); expect(oauthService.linkProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.GOOGLE, mockOAuthProfile, mockOAuthTokens, ); }); it('should link Microsoft provider to existing account', async () => { const microsoftConnection: OAuthConnectionResponse = { ...mockOAuthConnection, provider: OAuthProvider.MICROSOFT, provider_email: 'user@outlook.com', }; oauthService.linkProvider.mockResolvedValue(microsoftConnection); const body = { profile: { ...mockOAuthProfile, email: 'user@outlook.com' }, tokens: mockOAuthTokens, }; const result = await controller.linkProvider( 'microsoft', body, mockRequestUser, ); expect(result.provider).toBe(OAuthProvider.MICROSOFT); expect(oauthService.linkProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.MICROSOFT, expect.objectContaining({ email: 'user@outlook.com' }), mockOAuthTokens, ); }); it('should link GitHub provider to existing account', async () => { const githubConnection: OAuthConnectionResponse = { ...mockOAuthConnection, provider: OAuthProvider.GITHUB, provider_email: 'dev@github.com', }; oauthService.linkProvider.mockResolvedValue(githubConnection); const body = { profile: { ...mockOAuthProfile, email: 'dev@github.com' }, tokens: mockOAuthTokens, }; const result = await controller.linkProvider( 'github', body, mockRequestUser, ); expect(result.provider).toBe(OAuthProvider.GITHUB); }); it('should link Apple provider to existing account', async () => { const appleConnection: OAuthConnectionResponse = { ...mockOAuthConnection, provider: OAuthProvider.APPLE, provider_email: 'user@privaterelay.appleid.com', }; oauthService.linkProvider.mockResolvedValue(appleConnection); const body = { profile: { ...mockOAuthProfile, email: 'user@privaterelay.appleid.com', }, tokens: mockOAuthTokens, }; const result = await controller.linkProvider( 'apple', body, mockRequestUser, ); expect(result.provider).toBe(OAuthProvider.APPLE); }); it('should handle link with minimal profile data', async () => { oauthService.linkProvider.mockResolvedValue(mockOAuthConnection); const minimalProfile: OAuthProfile = { id: 'min-id', email: 'minimal@test.com', }; const body = { profile: minimalProfile, tokens: mockOAuthTokens, }; const result = await controller.linkProvider( 'google', body, mockRequestUser, ); expect(result).toEqual(mockOAuthConnection); expect(oauthService.linkProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.GOOGLE, minimalProfile, mockOAuthTokens, ); }); it('should handle uppercase provider names when linking', async () => { oauthService.linkProvider.mockResolvedValue(mockOAuthConnection); const body = { profile: mockOAuthProfile, tokens: mockOAuthTokens, }; await controller.linkProvider('GOOGLE', body, mockRequestUser); expect(oauthService.linkProvider).toHaveBeenCalledWith( mockRequestUser.id, mockRequestUser.tenant_id, OAuthProvider.GOOGLE, mockOAuthProfile, mockOAuthTokens, ); }); }); describe('error cases', () => { it('should throw BadRequestException when profile is missing', async () => { const body = { tokens: mockOAuthTokens, } as any; await expect( controller.linkProvider('google', body, mockRequestUser), ).rejects.toThrow(BadRequestException); await expect( controller.linkProvider('google', body, mockRequestUser), ).rejects.toThrow('Se requiere profile y tokens del proveedor OAuth'); }); it('should throw BadRequestException when tokens are missing', async () => { const body = { profile: mockOAuthProfile, } as any; await expect( controller.linkProvider('google', body, mockRequestUser), ).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException when both profile and tokens are missing', async () => { const body = {} as any; await expect( controller.linkProvider('google', body, mockRequestUser), ).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException for invalid provider', async () => { const body = { profile: mockOAuthProfile, tokens: mockOAuthTokens, }; await expect( controller.linkProvider('facebook', body, mockRequestUser), ).rejects.toThrow(BadRequestException); }); it('should propagate ConflictException when provider already linked to same user', async () => { const conflictError = new Error('Ya tienes vinculado google'); oauthService.linkProvider.mockRejectedValue(conflictError); const body = { profile: mockOAuthProfile, tokens: mockOAuthTokens, }; await expect( controller.linkProvider('google', body, mockRequestUser), ).rejects.toThrow('Ya tienes vinculado google'); }); it('should propagate ConflictException when provider account linked to another user', async () => { const conflictError = new Error( 'Esta cuenta de google ya esta vinculada a otro usuario', ); oauthService.linkProvider.mockRejectedValue(conflictError); const body = { profile: mockOAuthProfile, tokens: mockOAuthTokens, }; await expect( controller.linkProvider('google', body, mockRequestUser), ).rejects.toThrow('Esta cuenta de google ya esta vinculada a otro usuario'); }); }); }); // ==================== validateProvider Private Method Tests ==================== describe('validateProvider (through public methods)', () => { it('should accept all valid providers', () => { oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); // Test all valid providers through getAuthorizationUrl const providers = ['google', 'microsoft', 'github', 'apple']; providers.forEach((provider) => { expect(() => controller.getAuthorizationUrl(provider, mockTenantId), ).not.toThrow(); }); }); it('should reject invalid providers with descriptive error', () => { const invalidProviders = [ 'facebook', 'twitter', 'linkedin', 'unknown', '', ' ', 'null', 'undefined', ]; invalidProviders.forEach((provider) => { expect(() => controller.getAuthorizationUrl(provider, mockTenantId), ).toThrow(BadRequestException); }); }); it('should be case-insensitive for provider validation', () => { oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); const casedProviders = [ 'GOOGLE', 'Google', 'gOoGlE', 'MICROSOFT', 'Microsoft', 'GITHUB', 'GitHub', 'gitHub', 'APPLE', 'Apple', ]; casedProviders.forEach((provider) => { expect(() => controller.getAuthorizationUrl(provider, mockTenantId), ).not.toThrow(); }); }); }); // ==================== Integration/Flow Tests ==================== describe('OAuth Flow Integration', () => { it('should complete full OAuth login flow for new user', async () => { // Step 1: Get authorization URL oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); const urlResult = controller.getAuthorizationUrl('google', mockTenantId); expect(urlResult.url).toContain('accounts.google.com'); // Step 2: Handle callback (simulating new user) const newUserAuthResponse = { user: { ...mockUser, id: 'new-user-uuid' }, accessToken: 'new_user_access_token', refreshToken: 'new_user_refresh_token', }; oauthService.handleOAuthLogin.mockResolvedValue(newUserAuthResponse); const callbackResult = await controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ); expect(callbackResult).toHaveProperty('user'); expect(callbackResult).toHaveProperty('accessToken'); expect(callbackResult).toHaveProperty('refreshToken'); }); it('should complete full OAuth login flow for existing user', async () => { // Step 1: Get authorization URL oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); const urlResult = controller.getAuthorizationUrl('google', mockTenantId); expect(urlResult).toBeDefined(); // Step 2: Handle callback (simulating existing user) oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const callbackResult = await controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ); expect(callbackResult.user.id).toBe(mockUserId); }); it('should allow linking multiple providers to same account', async () => { const googleConnection = { ...mockOAuthConnection, provider: OAuthProvider.GOOGLE }; const githubConnection = { ...mockOAuthConnection, id: 'github-conn-id', provider: OAuthProvider.GITHUB }; oauthService.linkProvider .mockResolvedValueOnce(googleConnection) .mockResolvedValueOnce(githubConnection); // Link Google const googleResult = await controller.linkProvider( 'google', { profile: mockOAuthProfile, tokens: mockOAuthTokens }, mockRequestUser, ); expect(googleResult.provider).toBe(OAuthProvider.GOOGLE); // Link GitHub const githubResult = await controller.linkProvider( 'github', { profile: { ...mockOAuthProfile, id: 'github-id' }, tokens: mockOAuthTokens }, mockRequestUser, ); expect(githubResult.provider).toBe(OAuthProvider.GITHUB); }); it('should show all connections after linking multiple providers', async () => { const multipleConnections: OAuthConnectionResponse[] = [ { ...mockOAuthConnection, provider: OAuthProvider.GOOGLE }, { ...mockOAuthConnection, id: 'ms-conn-id', provider: OAuthProvider.MICROSOFT }, { ...mockOAuthConnection, id: 'gh-conn-id', provider: OAuthProvider.GITHUB }, ]; oauthService.getConnections.mockResolvedValue(multipleConnections); const connections = await controller.getConnections(mockRequestUser); expect(connections).toHaveLength(3); expect(connections.map((c) => c.provider)).toEqual([ OAuthProvider.GOOGLE, OAuthProvider.MICROSOFT, OAuthProvider.GITHUB, ]); }); }); // ==================== Session Management Tests ==================== describe('Session Management after OAuth', () => { it('should return valid JWT tokens after successful OAuth login', async () => { oauthService.handleOAuthLogin.mockResolvedValue({ user: mockUser, accessToken: 'valid_jwt_access_token', refreshToken: 'valid_jwt_refresh_token', }); const result = await controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ); expect(result.accessToken).toBe('valid_jwt_access_token'); expect(result.refreshToken).toBe('valid_jwt_refresh_token'); expect(result.accessToken).not.toBe(mockOAuthTokens.access_token); }); it('should include user agent in OAuth login request', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); await controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ); expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( expect.any(String), expect.any(Object), expect.any(Object), mockTenantId, expect.objectContaining({ headers: expect.objectContaining({ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', }), }), ); }); it('should include IP address in OAuth login request', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); await controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ); expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( expect.any(String), expect.any(Object), expect.any(Object), mockTenantId, expect.objectContaining({ ip: '127.0.0.1', }), ); }); }); // ==================== Edge Cases ==================== describe('Edge Cases', () => { it('should handle OAuth profile with special characters in name', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const profileWithSpecialChars: OAuthProfile = { id: 'special-id', email: 'test@example.com', name: "O'Connor, Jose Maria", avatar_url: 'https://example.com/avatar.jpg', }; await expect( controller.handleCallback( 'google', { code: 'auth_code', profile: profileWithSpecialChars, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ), ).resolves.not.toThrow(); }); it('should handle OAuth profile with unicode characters', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const profileWithUnicode: OAuthProfile = { id: 'unicode-id', email: 'test@example.com', name: 'Juan Carlos', }; await expect( controller.handleCallback( 'google', { code: 'auth_code', profile: profileWithUnicode, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ), ).resolves.not.toThrow(); }); it('should handle very long OAuth token strings', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const longTokens: OAuthTokens = { access_token: 'a'.repeat(10000), refresh_token: 'r'.repeat(5000), }; await expect( controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: longTokens, }, mockTenantId, mockRequest as any, ), ).resolves.not.toThrow(); }); it('should handle empty state in callback body', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const result = await controller.handleCallback( 'google', { code: 'auth_code', state: '', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ); expect(result).toEqual(mockAuthResponse); }); it('should handle null values in OAuth profile optional fields', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const profileWithNulls: OAuthProfile = { id: 'null-fields-id', email: 'test@example.com', name: undefined, avatar_url: undefined, raw_data: undefined, }; await expect( controller.handleCallback( 'google', { code: 'auth_code', profile: profileWithNulls, tokens: mockOAuthTokens, }, mockTenantId, mockRequest as any, ), ).resolves.not.toThrow(); }); it('should handle request without user-agent header', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const requestWithoutUserAgent = { ip: '127.0.0.1', headers: { 'x-tenant-id': mockTenantId, }, }; await expect( controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, requestWithoutUserAgent as any, ), ).resolves.not.toThrow(); }); it('should handle request without IP address', async () => { oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); const requestWithoutIp = { headers: { 'user-agent': 'Mozilla/5.0', 'x-tenant-id': mockTenantId, }, }; await expect( controller.handleCallback( 'google', { code: 'auth_code', profile: mockOAuthProfile, tokens: mockOAuthTokens, }, mockTenantId, requestWithoutIp as any, ), ).resolves.not.toThrow(); }); }); });