template-saas-backend-v2/src/modules/auth/__tests__/oauth.controller.spec.ts
rckrdmrd dfe6a715f0 Initial commit - Backend de template-saas migrado desde monorepo
Migración desde workspace-v2/projects/template-saas/apps/backend
Este repositorio es parte del estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:07:11 -06:00

1395 lines
42 KiB
TypeScript

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<OAuthService>;
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<string, string> = {
'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>(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();
});
});
});