managarten/services/mana-core-auth/src/auth/auth.controller.spec.ts
2025-12-03 23:42:37 +01:00

710 lines
22 KiB
TypeScript

/**
* 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, 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<BetterAuthService>;
// 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>(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: '',
});
});
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('');
});
});
});