diff --git a/services/mana-core-auth/src/auth/magic-link.spec.ts b/services/mana-core-auth/src/auth/magic-link.spec.ts new file mode 100644 index 000000000..8f4a6e862 --- /dev/null +++ b/services/mana-core-auth/src/auth/magic-link.spec.ts @@ -0,0 +1,140 @@ +/** + * Magic Link Passthrough Unit Tests + * + * Tests that the BetterAuthPassthroughController has the magic link + * handler method and that it delegates to forwardToBetterAuth. + */ + +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { BetterAuthPassthroughController } from './better-auth-passthrough.controller'; +import { BetterAuthService } from './services/better-auth.service'; +import { LoggerService } from '../common/logger'; + +describe('BetterAuthPassthroughController - Magic Link', () => { + let controller: BetterAuthPassthroughController; + let betterAuthService: jest.Mocked; + + const mockBetterAuthService = { + getHandler: jest.fn(), + verifyEmail: jest.fn(), + getSourceAppUrl: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + const config: Record = { + BASE_URL: 'http://localhost:3001', + }; + return config[key] || ''; + }), + }; + + const mockLoggerService = { + setContext: jest.fn().mockReturnThis(), + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BetterAuthPassthroughController], + providers: [ + { provide: BetterAuthService, useValue: mockBetterAuthService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: LoggerService, useValue: mockLoggerService }, + ], + }).compile(); + + controller = module.get(BetterAuthPassthroughController); + betterAuthService = module.get(BetterAuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================================================ + // Magic Link Handler Existence + // ============================================================================ + + describe('handleMagicLink', () => { + it('should have handleMagicLink method defined', () => { + expect(controller.handleMagicLink).toBeDefined(); + expect(typeof controller.handleMagicLink).toBe('function'); + }); + + it('should call forwardToBetterAuth and delegate to Better Auth handler', async () => { + const mockResponse = new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + const mockHandler = jest.fn().mockResolvedValue(mockResponse); + betterAuthService.getHandler.mockReturnValue(mockHandler); + + const mockReq = { + method: 'POST', + originalUrl: '/api/auth/magic-link/send-magic-link', + headers: { 'content-type': 'application/json' }, + body: { email: 'test@example.com' }, + } as any; + + const mockRes = { + status: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + append: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + } as any; + + await controller.handleMagicLink(mockReq, mockRes); + + expect(betterAuthService.getHandler).toHaveBeenCalled(); + expect(mockHandler).toHaveBeenCalled(); + }); + + it('should return 500 on internal error', async () => { + betterAuthService.getHandler.mockImplementation(() => { + throw new Error('Handler unavailable'); + }); + + const mockReq = { + method: 'POST', + originalUrl: '/api/auth/magic-link/send-magic-link', + headers: {}, + body: {}, + } as any; + + const mockRes = { + status: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + append: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + } as any; + + await controller.handleMagicLink(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Magic link request failed' }); + }); + }); + + // ============================================================================ + // Route Metadata + // ============================================================================ + + describe('Route metadata', () => { + it('should have @All decorator on handleMagicLink for magic-link/* routes', () => { + const routePath = Reflect.getMetadata( + 'path', + BetterAuthPassthroughController.prototype.handleMagicLink + ); + expect(routePath).toBe('magic-link/*'); + }); + }); +}); diff --git a/services/mana-core-auth/src/auth/security-events-controller.spec.ts b/services/mana-core-auth/src/auth/security-events-controller.spec.ts new file mode 100644 index 000000000..e71ea4a32 --- /dev/null +++ b/services/mana-core-auth/src/auth/security-events-controller.spec.ts @@ -0,0 +1,196 @@ +/** + * AuthController Security Events Unit Tests + * + * Tests the security events / audit log endpoint on the AuthController: + * + * - GET /auth/security-events - List user's security events + */ + +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { AuthController } from './auth.controller'; +import { BetterAuthService } from './services/better-auth.service'; +import { PasskeyService } from './services/passkey.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security'; + +describe('AuthController - Security Events', () => { + let controller: AuthController; + let betterAuthService: jest.Mocked; + + const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' }; + const mockReq = { + headers: { 'user-agent': 'test-agent' }, + ip: '127.0.0.1', + } as any; + + beforeEach(async () => { + const mockPasskeyService = { + generateRegistrationOptions: jest.fn(), + verifyRegistration: jest.fn(), + generateAuthenticationOptions: jest.fn(), + verifyAuthentication: jest.fn(), + listPasskeys: jest.fn(), + deletePasskey: jest.fn(), + renamePasskey: jest.fn(), + }; + + 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(), + createSessionAndTokens: jest.fn(), + requestPasswordReset: jest.fn(), + resetPassword: jest.fn(), + resendVerificationEmail: jest.fn(), + getProfile: jest.fn(), + updateProfile: jest.fn(), + changePassword: jest.fn(), + deleteAccount: jest.fn(), + sessionToToken: jest.fn(), + getJwks: jest.fn(), + updateOrganization: jest.fn(), + deleteOrganization: jest.fn(), + updateMemberRole: jest.fn(), + listOrganizationInvitations: jest.fn(), + listUserInvitations: jest.fn(), + cancelInvitation: jest.fn(), + rejectInvitation: jest.fn(), + getSecurityEvents: jest.fn(), + }; + + const mockSecurityEventsService = { + logEvent: jest.fn().mockResolvedValue(undefined), + logEventWithRequest: jest.fn().mockResolvedValue(undefined), + extractRequestInfo: jest.fn().mockReturnValue({ + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + }), + }; + + const mockAccountLockoutService = { + checkLockout: jest.fn().mockResolvedValue({ locked: false }), + recordAttempt: jest.fn().mockResolvedValue(undefined), + clearAttempts: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])], + controllers: [AuthController], + providers: [ + { provide: BetterAuthService, useValue: mockBetterAuthService }, + { provide: PasskeyService, useValue: mockPasskeyService }, + { provide: SecurityEventsService, useValue: mockSecurityEventsService }, + { provide: AccountLockoutService, useValue: mockAccountLockoutService }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: jest.fn(() => true) }) + .overrideGuard(ThrottlerGuard) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + controller = module.get(AuthController); + betterAuthService = module.get(BetterAuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================================================ + // GET /auth/security-events + // ============================================================================ + + describe('GET /auth/security-events', () => { + it("should return user's events from BetterAuthService", async () => { + const mockEvents = [ + { + id: 'evt-1', + eventType: 'login_success', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: { email: 'test@example.com' }, + createdAt: new Date('2026-03-27T10:00:00Z'), + }, + { + id: 'evt-2', + eventType: 'password_changed', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date('2026-03-26T09:00:00Z'), + }, + ]; + + betterAuthService.getSecurityEvents.mockResolvedValue(mockEvents); + + const result = await controller.getSecurityEvents(mockUser as any, mockReq); + + expect(result).toEqual(mockEvents); + expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123'); + }); + + it('should return empty array when no events exist', async () => { + betterAuthService.getSecurityEvents.mockResolvedValue([]); + + const result = await controller.getSecurityEvents(mockUser as any, mockReq); + + expect(result).toEqual([]); + expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123'); + }); + + it('should return events in descending order by createdAt', async () => { + const newerEvent = { + id: 'evt-1', + eventType: 'login_success', + ipAddress: '127.0.0.1', + userAgent: 'test', + metadata: {}, + createdAt: new Date('2026-03-27T12:00:00Z'), + }; + const olderEvent = { + id: 'evt-2', + eventType: 'logout', + ipAddress: '127.0.0.1', + userAgent: 'test', + metadata: {}, + createdAt: new Date('2026-03-26T08:00:00Z'), + }; + + // BetterAuthService already orders them desc by createdAt + betterAuthService.getSecurityEvents.mockResolvedValue([newerEvent, olderEvent]); + + const result = await controller.getSecurityEvents(mockUser as any, mockReq); + + expect(result).toHaveLength(2); + expect(new Date(result[0].createdAt).getTime()).toBeGreaterThan( + new Date(result[1].createdAt).getTime() + ); + }); + }); + + // ============================================================================ + // Guard Configuration + // ============================================================================ + + describe('Security Events Guard Configuration', () => { + it('should have JwtAuthGuard on getSecurityEvents', () => { + const guards = Reflect.getMetadata('__guards__', AuthController.prototype.getSecurityEvents); + expect(guards).toBeDefined(); + expect(guards).toContain(JwtAuthGuard); + }); + }); +}); diff --git a/services/mana-core-auth/src/auth/services/audit-log.spec.ts b/services/mana-core-auth/src/auth/services/audit-log.spec.ts new file mode 100644 index 000000000..3934e7892 --- /dev/null +++ b/services/mana-core-auth/src/auth/services/audit-log.spec.ts @@ -0,0 +1,156 @@ +/** + * BetterAuthService.getSecurityEvents Unit Tests + * + * Tests the audit log / security events query method. + * Uses the thenable DB mock pattern from passkey.service.spec.ts. + * + * Since BetterAuthService has complex constructor dependencies (Better Auth, + * OIDC provider), we mock the better-auth.config module and the DB connection. + */ + +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { getDb } from '../../db/connection'; +import { LoggerService } from '../../common/logger'; + +// Mock better-auth config to avoid oidcProvider instantiation +jest.mock('../better-auth.config', () => ({ + createBetterAuth: jest.fn(() => ({ + api: {}, + handler: jest.fn(), + })), +})); + +jest.mock('../../db/connection', () => ({ + getDb: jest.fn(), +})); + +const createMockDb = () => { + let results: any[] = []; + let resultIndex = 0; + + const db: any = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + then: jest.fn((resolve) => resolve(results[resultIndex++] || [])), + setResults: (...r: any[]) => { + results = r; + resultIndex = 0; + }, + }; + return db; +}; + +// Import after mocks are set up +import { BetterAuthService } from './better-auth.service'; + +describe('BetterAuthService - getSecurityEvents', () => { + let service: BetterAuthService; + let mockDb: ReturnType; + + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: string) => { + const config: Record = { + 'database.url': 'postgresql://test:test@localhost:5432/test', + DATABASE_URL: 'postgresql://test:test@localhost:5432/test', + JWT_ISSUER: 'manacore', + JWT_AUDIENCE: 'manacore', + BASE_URL: 'http://localhost:3001', + }; + return config[key] || defaultValue || ''; + }), + }; + + const mockLoggerService = { + setContext: jest.fn().mockReturnThis(), + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + beforeEach(async () => { + mockDb = createMockDb(); + (getDb as jest.Mock).mockReturnValue(mockDb); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BetterAuthService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: LoggerService, useValue: mockLoggerService }, + ], + }).compile(); + + service = module.get(BetterAuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return events for a given userId ordered by createdAt desc', async () => { + const mockEvents = [ + { + id: 'evt-1', + eventType: 'login_success', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: { email: 'test@example.com' }, + createdAt: new Date('2026-03-27T10:00:00Z'), + }, + { + id: 'evt-2', + eventType: 'logout', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date('2026-03-26T09:00:00Z'), + }, + ]; + + mockDb.setResults(mockEvents); + + const result = await service.getSecurityEvents('user-123'); + + expect(result).toEqual(mockEvents); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + expect(mockDb.orderBy).toHaveBeenCalled(); + expect(mockDb.limit).toHaveBeenCalled(); + }); + + it('should limit results to default of 50', async () => { + mockDb.setResults([]); + + await service.getSecurityEvents('user-123'); + + expect(mockDb.limit).toHaveBeenCalledWith(50); + }); + + it('should respect custom limit parameter', async () => { + mockDb.setResults([]); + + await service.getSecurityEvents('user-123', 10); + + expect(mockDb.limit).toHaveBeenCalledWith(10); + }); + + it('should return empty array when no events exist', async () => { + mockDb.setResults([]); + + const result = await service.getSecurityEvents('user-123'); + + expect(result).toEqual([]); + }); +}); diff --git a/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts b/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts index bbce33aee..cda3f3a42 100644 --- a/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts +++ b/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts @@ -453,6 +453,61 @@ describe('Passkey & 2FA (E2E)', () => { }); }); + // ========================================================================= + // Magic Link Flow + // ========================================================================= + + describe('Magic Link Flow', () => { + it('POST /api/auth/magic-link/send-magic-link should be routable', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/magic-link/send-magic-link') + .send({ email: 'test@example.com' }); + // Should not be 404 (route exists) + expect(res.status).not.toBe(404); + }); + + it('GET /api/auth/magic-link/verify should be routable', async () => { + const res = await request(app.getHttpServer()) + .get('/api/auth/magic-link/verify') + .query({ token: 'invalid-token' }); + expect(res.status).not.toBe(404); + }); + }); + + // ========================================================================= + // Security Events / Audit Log + // ========================================================================= + + describe('Security Events / Audit Log', () => { + it('GET /auth/security-events requires authentication', async () => { + const res = await request(app.getHttpServer()).get('/auth/security-events'); + expect(res.status).toBe(401); + }); + + it('GET /auth/security-events returns events for authenticated user', async () => { + const res = await request(app.getHttpServer()) + .get('/auth/security-events') + .set('Authorization', `Bearer ${accessToken}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('GET /auth/security-events returns events with expected shape', async () => { + const res = await request(app.getHttpServer()) + .get('/auth/security-events') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + // User has logged in at least once, so there should be events + if (res.body.length > 0) { + const event = res.body[0]; + expect(event).toHaveProperty('id'); + expect(event).toHaveProperty('eventType'); + expect(event).toHaveProperty('createdAt'); + } + }); + }); + // ========================================================================= // Edge Cases // =========================================================================