/** * AuthController Unit Tests * * Tests all authentication controller endpoints using BetterAuthService: * * B2C Endpoints: * - POST /auth/register - User registration * - POST /auth/login - User login * - POST /auth/logout - User logout * - POST /auth/refresh - Token refresh * - GET /auth/session - Get current session * - POST /auth/validate - Token validation * * B2B Endpoints: * - POST /auth/register/b2b - Organization registration * - GET /auth/organizations - List organizations * - GET /auth/organizations/:id - Get organization * - GET /auth/organizations/:id/members - Get organization members * - POST /auth/organizations/:id/invite - Invite employee * - POST /auth/organizations/accept-invitation - Accept invitation * - DELETE /auth/organizations/:id/members/:memberId - Remove member * - POST /auth/organizations/set-active - Set active organization */ import { Test } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing'; import { UnauthorizedException, ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { BetterAuthService } from './services/better-auth.service'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { mockDtoFactory } from '../__tests__/utils/mock-factories'; describe('AuthController', () => { let controller: AuthController; let betterAuthService: jest.Mocked; // Common test data const mockAuthHeader = 'Bearer valid-jwt-token'; const mockToken = 'valid-jwt-token'; beforeEach(async () => { // Create mock BetterAuthService with all methods const mockBetterAuthService = { registerB2C: jest.fn(), registerB2B: jest.fn(), signIn: jest.fn(), signOut: jest.fn(), getSession: jest.fn(), listOrganizations: jest.fn(), getOrganization: jest.fn(), getOrganizationMembers: jest.fn(), inviteEmployee: jest.fn(), acceptInvitation: jest.fn(), removeMember: jest.fn(), setActiveOrganization: jest.fn(), refreshToken: jest.fn(), validateToken: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], providers: [ { provide: BetterAuthService, useValue: mockBetterAuthService, }, ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: jest.fn(() => true) }) .compile(); controller = module.get(AuthController); betterAuthService = module.get(BetterAuthService); }); afterEach(() => { jest.clearAllMocks(); }); // ============================================================================ // POST /auth/register (B2C) // ============================================================================ describe('POST /auth/register', () => { it('should successfully register a new B2C user', async () => { const registerDto = mockDtoFactory.register({ email: 'newuser@example.com', password: 'SecurePassword123!', name: 'New User', }); const expectedResult = { user: { id: 'user-123', email: registerDto.email, name: registerDto.name, }, token: 'jwt-token', }; betterAuthService.registerB2C.mockResolvedValue(expectedResult); const result = await controller.register(registerDto); expect(result).toEqual(expectedResult); expect(betterAuthService.registerB2C).toHaveBeenCalledWith({ email: registerDto.email, password: registerDto.password, name: registerDto.name, }); }); it('should handle registration without name', async () => { const registerDto = { email: 'noname@example.com', password: 'SecurePassword123!', }; const expectedResult = { user: { id: 'user-456', email: registerDto.email, name: '' }, token: 'jwt-token', }; betterAuthService.registerB2C.mockResolvedValue(expectedResult); const result = await controller.register(registerDto as any); expect(result).toEqual(expectedResult); expect(betterAuthService.registerB2C).toHaveBeenCalledWith({ email: registerDto.email, password: registerDto.password, name: undefined, // Controller passes undefined when name is not provided }); }); it('should propagate ConflictException when user exists', async () => { const registerDto = mockDtoFactory.register({ email: 'existing@example.com' }); betterAuthService.registerB2C.mockRejectedValue( new ConflictException('User with this email already exists') ); await expect(controller.register(registerDto)).rejects.toThrow(ConflictException); }); }); // ============================================================================ // POST /auth/login // ============================================================================ describe('POST /auth/login', () => { it('should successfully login a user', async () => { const loginDto = mockDtoFactory.login({ email: 'user@example.com', password: 'SecurePassword123!', }); const expectedResult = { user: { id: 'user-123', email: loginDto.email, name: 'Test User', role: 'user', }, accessToken: 'jwt-access-token', refreshToken: 'session-refresh-token', expiresIn: 900, }; betterAuthService.signIn.mockResolvedValue(expectedResult); const result = await controller.login(loginDto); expect(result).toEqual(expectedResult); expect(betterAuthService.signIn).toHaveBeenCalledWith({ email: loginDto.email, password: loginDto.password, deviceId: undefined, deviceName: undefined, }); }); it('should pass device info when provided', async () => { const loginDto = { email: 'user@example.com', password: 'SecurePassword123!', deviceId: 'device-abc-123', deviceName: 'iPhone 15 Pro', }; betterAuthService.signIn.mockResolvedValue({ user: { id: '123', email: 'user@example.com', name: 'Test', role: 'user' }, accessToken: 'jwt-token', refreshToken: 'refresh-token', expiresIn: 900, }); await controller.login(loginDto); expect(betterAuthService.signIn).toHaveBeenCalledWith({ email: loginDto.email, password: loginDto.password, deviceId: 'device-abc-123', deviceName: 'iPhone 15 Pro', }); }); it('should propagate UnauthorizedException for invalid credentials', async () => { const loginDto = mockDtoFactory.login({ password: 'WrongPassword' }); betterAuthService.signIn.mockRejectedValue( new UnauthorizedException('Invalid email or password') ); await expect(controller.login(loginDto)).rejects.toThrow(UnauthorizedException); }); }); // ============================================================================ // POST /auth/logout // ============================================================================ describe('POST /auth/logout', () => { it('should successfully logout a user', async () => { const expectedResult = { success: true, message: 'Signed out successfully' }; betterAuthService.signOut.mockResolvedValue(expectedResult); const result = await controller.logout(mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.signOut).toHaveBeenCalledWith(mockToken); }); it('should extract token from Bearer header', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' }); await controller.logout('Bearer my-secret-token'); expect(betterAuthService.signOut).toHaveBeenCalledWith('my-secret-token'); }); it('should handle raw token without Bearer prefix', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' }); await controller.logout('raw-token'); expect(betterAuthService.signOut).toHaveBeenCalledWith('raw-token'); }); }); // ============================================================================ // POST /auth/refresh // ============================================================================ describe('POST /auth/refresh', () => { it('should successfully refresh tokens', async () => { const refreshTokenDto = { refreshToken: 'valid-refresh-token' }; const expectedResult = { accessToken: 'new-access-token', refreshToken: 'new-refresh-token', expiresIn: 900, tokenType: 'Bearer', user: { id: 'user-123', email: 'user@example.com', name: 'Test', role: 'user' as const }, }; betterAuthService.refreshToken.mockResolvedValue(expectedResult); const result = await controller.refresh(refreshTokenDto); expect(result).toEqual(expectedResult); expect(betterAuthService.refreshToken).toHaveBeenCalledWith('valid-refresh-token'); }); it('should propagate UnauthorizedException for invalid refresh token', async () => { const refreshTokenDto = { refreshToken: 'invalid-token' }; betterAuthService.refreshToken.mockRejectedValue( new UnauthorizedException('Invalid refresh token') ); await expect(controller.refresh(refreshTokenDto)).rejects.toThrow(UnauthorizedException); }); }); // ============================================================================ // GET /auth/session // ============================================================================ describe('GET /auth/session', () => { it('should return current session', async () => { const expectedResult = { user: { id: 'user-123', email: 'user@example.com', name: 'Test' }, session: { id: 'session-123', activeOrganizationId: null }, }; betterAuthService.getSession.mockResolvedValue(expectedResult as any); const result = await controller.getSession(mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.getSession).toHaveBeenCalledWith(mockToken); }); it('should propagate UnauthorizedException for invalid session', async () => { betterAuthService.getSession.mockRejectedValue( new UnauthorizedException('Invalid or expired session') ); await expect(controller.getSession(mockAuthHeader)).rejects.toThrow(UnauthorizedException); }); }); // ============================================================================ // POST /auth/validate // ============================================================================ describe('POST /auth/validate', () => { it('should return valid for a valid token', async () => { const body = { token: 'valid-jwt-token' }; const expectedResult = { valid: true, payload: { sub: 'user-123', email: 'user@example.com', role: 'user' }, }; betterAuthService.validateToken.mockResolvedValue(expectedResult as any); const result = await controller.validate(body); expect(result).toEqual(expectedResult); expect(betterAuthService.validateToken).toHaveBeenCalledWith(body.token); }); it('should return invalid for expired token', async () => { const body = { token: 'expired-token' }; betterAuthService.validateToken.mockResolvedValue({ valid: false, error: 'Token expired', } as any); const result = await controller.validate(body); expect((result as any).valid).toBe(false); }); }); // ============================================================================ // POST /auth/register/b2b // ============================================================================ describe('POST /auth/register/b2b', () => { it('should successfully register a B2B organization', async () => { const registerDto = { ownerEmail: 'owner@acme.com', password: 'SecurePassword123!', ownerName: 'John Owner', organizationName: 'Acme Corporation', }; const expectedResult = { user: { id: 'user-123', email: registerDto.ownerEmail, name: registerDto.ownerName }, organization: { id: 'org-456', name: 'Acme Corporation', slug: 'acme-corporation' }, token: 'jwt-token', }; betterAuthService.registerB2B.mockResolvedValue(expectedResult as any); const result = await controller.registerB2B(registerDto); expect(result).toEqual(expectedResult); expect(betterAuthService.registerB2B).toHaveBeenCalledWith(registerDto); }); it('should propagate ConflictException when owner email exists', async () => { const registerDto = { ownerEmail: 'existing@acme.com', password: 'SecurePassword123!', ownerName: 'John', organizationName: 'Acme', }; betterAuthService.registerB2B.mockRejectedValue( new ConflictException('Owner email already exists') ); await expect(controller.registerB2B(registerDto)).rejects.toThrow(ConflictException); }); }); // ============================================================================ // GET /auth/organizations // ============================================================================ describe('GET /auth/organizations', () => { it('should list user organizations', async () => { const expectedResult = { organizations: [ { id: 'org-1', name: 'Org One', slug: 'org-one' }, { id: 'org-2', name: 'Org Two', slug: 'org-two' }, ], }; betterAuthService.listOrganizations.mockResolvedValue(expectedResult as any); const result = await controller.listOrganizations(mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.listOrganizations).toHaveBeenCalledWith(mockToken); }); it('should return empty array when user has no organizations', async () => { betterAuthService.listOrganizations.mockResolvedValue({ organizations: [] }); const result = await controller.listOrganizations(mockAuthHeader); expect(result.organizations).toEqual([]); }); }); // ============================================================================ // GET /auth/organizations/:id // ============================================================================ describe('GET /auth/organizations/:id', () => { it('should get organization details', async () => { const orgId = 'org-123'; const expectedResult = { id: orgId, name: 'Acme Corp', slug: 'acme-corp', members: [{ id: 'member-1', userId: 'user-1', role: 'owner' }], }; betterAuthService.getOrganization.mockResolvedValue(expectedResult as any); const result = await controller.getOrganization(orgId, mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.getOrganization).toHaveBeenCalledWith(orgId, mockToken); }); it('should throw NotFoundException when organization not found', async () => { betterAuthService.getOrganization.mockRejectedValue( new NotFoundException('Organization not found') ); await expect(controller.getOrganization('invalid-id', mockAuthHeader)).rejects.toThrow( NotFoundException ); }); }); // ============================================================================ // GET /auth/organizations/:id/members // ============================================================================ describe('GET /auth/organizations/:id/members', () => { it('should get organization members', async () => { const orgId = 'org-123'; const expectedMembers = [ { id: 'member-1', userId: 'user-1', organizationId: orgId, role: 'owner' }, { id: 'member-2', userId: 'user-2', organizationId: orgId, role: 'member' }, ]; betterAuthService.getOrganizationMembers.mockResolvedValue(expectedMembers as any); const result = await controller.getOrganizationMembers(orgId); expect(result).toEqual(expectedMembers); expect(betterAuthService.getOrganizationMembers).toHaveBeenCalledWith(orgId); }); }); // ============================================================================ // POST /auth/organizations/:id/invite // ============================================================================ describe('POST /auth/organizations/:id/invite', () => { it('should invite an employee to organization', async () => { const orgId = 'org-123'; const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const, }; const expectedResult = { id: 'invitation-123', email: 'employee@acme.com', organizationId: orgId, role: 'member', status: 'pending', }; betterAuthService.inviteEmployee.mockResolvedValue(expectedResult as any); const result = await controller.inviteEmployee(orgId, inviteDto, mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.inviteEmployee).toHaveBeenCalledWith({ organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member', inviterToken: mockToken, }); }); it('should throw ForbiddenException when inviter lacks permission', async () => { const orgId = 'org-123'; const inviteDto = { organizationId: orgId, employeeEmail: 'employee@acme.com', role: 'member' as const, }; betterAuthService.inviteEmployee.mockRejectedValue( new ForbiddenException('You do not have permission to invite members') ); await expect(controller.inviteEmployee(orgId, inviteDto, mockAuthHeader)).rejects.toThrow( ForbiddenException ); }); }); // ============================================================================ // POST /auth/organizations/accept-invitation // ============================================================================ describe('POST /auth/organizations/accept-invitation', () => { it('should accept an invitation', async () => { const acceptDto = { invitationId: 'invitation-123' }; const expectedResult = { member: { id: 'member-123', userId: 'user-456', organizationId: 'org-123', role: 'member' }, organization: { id: 'org-123', name: 'Acme Corp' }, }; betterAuthService.acceptInvitation.mockResolvedValue(expectedResult as any); const result = await controller.acceptInvitation(acceptDto, mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.acceptInvitation).toHaveBeenCalledWith({ invitationId: 'invitation-123', userToken: mockToken, }); }); it('should throw NotFoundException when invitation not found', async () => { const acceptDto = { invitationId: 'invalid-invitation' }; betterAuthService.acceptInvitation.mockRejectedValue( new NotFoundException('Invitation not found or expired') ); await expect(controller.acceptInvitation(acceptDto, mockAuthHeader)).rejects.toThrow( NotFoundException ); }); }); // ============================================================================ // DELETE /auth/organizations/:id/members/:memberId // ============================================================================ describe('DELETE /auth/organizations/:id/members/:memberId', () => { it('should remove a member from organization', async () => { const orgId = 'org-123'; const memberId = 'member-456'; const expectedResult = { success: true, message: 'Member removed successfully' }; betterAuthService.removeMember.mockResolvedValue(expectedResult); const result = await controller.removeMember(orgId, memberId, mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.removeMember).toHaveBeenCalledWith({ organizationId: orgId, memberId, removerToken: mockToken, }); }); it('should throw ForbiddenException when remover lacks permission', async () => { betterAuthService.removeMember.mockRejectedValue( new ForbiddenException('You do not have permission to remove members') ); await expect( controller.removeMember('org-123', 'member-456', mockAuthHeader) ).rejects.toThrow(ForbiddenException); }); }); // ============================================================================ // POST /auth/organizations/set-active // ============================================================================ describe('POST /auth/organizations/set-active', () => { it('should set active organization', async () => { const setActiveDto = { organizationId: 'org-123' }; const expectedResult = { userId: 'user-123', activeOrganizationId: 'org-123', }; betterAuthService.setActiveOrganization.mockResolvedValue(expectedResult as any); const result = await controller.setActiveOrganization(setActiveDto, mockAuthHeader); expect(result).toEqual(expectedResult); expect(betterAuthService.setActiveOrganization).toHaveBeenCalledWith({ organizationId: 'org-123', userToken: mockToken, }); }); it('should throw NotFoundException when not a member', async () => { const setActiveDto = { organizationId: 'org-999' }; betterAuthService.setActiveOrganization.mockRejectedValue( new NotFoundException('Organization not found or you are not a member') ); await expect(controller.setActiveOrganization(setActiveDto, mockAuthHeader)).rejects.toThrow( NotFoundException ); }); }); // ============================================================================ // Guard Tests // ============================================================================ describe('Guards', () => { it('should have JwtAuthGuard on protected endpoints', () => { const protectedEndpoints: (keyof AuthController)[] = [ 'logout', 'getSession', 'listOrganizations', 'getOrganization', 'getOrganizationMembers', 'inviteEmployee', 'acceptInvitation', 'removeMember', 'setActiveOrganization', ]; protectedEndpoints.forEach((endpoint) => { const guards = Reflect.getMetadata( '__guards__', AuthController.prototype[endpoint as keyof AuthController] ); expect(guards).toBeDefined(); expect(guards).toContain(JwtAuthGuard); }); }); it('should NOT have JwtAuthGuard on public endpoints', () => { const publicEndpoints: (keyof AuthController)[] = [ 'register', 'login', 'refresh', 'validate', 'registerB2B', ]; publicEndpoints.forEach((endpoint) => { const guards = Reflect.getMetadata( '__guards__', AuthController.prototype[endpoint as keyof AuthController] ); expect(guards).toBeUndefined(); }); }); }); // ============================================================================ // Token Extraction Helper // ============================================================================ describe('Token Extraction', () => { it('should extract token from Bearer authorization header', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' }); await controller.logout('Bearer my-token-123'); expect(betterAuthService.signOut).toHaveBeenCalledWith('my-token-123'); }); it('should handle missing authorization header', async () => { betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' }); await controller.logout(''); expect(betterAuthService.signOut).toHaveBeenCalledWith(''); }); }); });