import { UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { AuthService } from './auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { AuditLogService } from '../audit/audit-log.service'; import { MfaService } from './mfa/mfa.service'; describe('AuthService', () => { let authService: AuthService; let usersService: { findByEmail: jest.Mock; findById: jest.Mock }; let prisma: { refreshToken: { create: jest.Mock; findUnique: jest.Mock; update: jest.Mock; updateMany: jest.Mock; }; }; let jwtService: { sign: jest.Mock }; let auditLog: { record: jest.Mock }; let permissionResolver: { getPermissions: jest.Mock; invalidateUser: jest.Mock; }; let mfaService: { signChallengeToken: jest.Mock; verifyChallengeToken: jest.Mock; verifyCode: jest.Mock; }; const activeUser = { id: 'user-1', email: 'user@example.com', isActive: true, passwordHash: '', mfaEnabled: false, mfaSecret: null as string | null, }; beforeEach(async () => { activeUser.passwordHash = await bcrypt.hash('correct-password', 4); activeUser.mfaEnabled = false; activeUser.mfaSecret = null; usersService = { findByEmail: jest.fn(), findById: jest.fn() }; prisma = { refreshToken: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn(), }, }; jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; permissionResolver = { getPermissions: jest.fn().mockResolvedValue(new Set()), invalidateUser: jest.fn(), }; mfaService = { signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'), verifyChallengeToken: jest.fn(), verifyCode: jest.fn(), }; authService = new AuthService( usersService as unknown as UsersService, prisma as unknown as PrismaService, jwtService as never, auditLog as unknown as AuditLogService, permissionResolver as never, mfaService as unknown as MfaService, ); }); describe('login', () => { it('throws when the user does not exist', async () => { usersService.findByEmail.mockResolvedValue(null); await expect( authService.login('missing@example.com', 'x'), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('throws when the user is deactivated', async () => { usersService.findByEmail.mockResolvedValue({ ...activeUser, isActive: false, }); await expect( authService.login(activeUser.email, 'correct-password'), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('throws when the password does not match', async () => { usersService.findByEmail.mockResolvedValue(activeUser); await expect( authService.login(activeUser.email, 'wrong-password'), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('returns tokens and stores a hashed refresh token on success', async () => { usersService.findByEmail.mockResolvedValue(activeUser); prisma.refreshToken.create.mockResolvedValue({}); const result = await authService.login( activeUser.email, 'correct-password', ); if (result.mfaRequired) { throw new Error('expected mfaRequired to be false'); } expect(result.accessToken).toBe('signed.jwt.token'); expect(result.refreshToken).toEqual(expect.any(String)); const [[createArg]] = prisma.refreshToken.create.mock.calls as [ [{ data: { userId: string } }], ]; expect(createArg.data.userId).toBe(activeUser.id); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }), ); }); it('returns an MFA challenge instead of tokens when MFA is enabled', async () => { usersService.findByEmail.mockResolvedValue({ ...activeUser, mfaEnabled: true, mfaSecret: 'encrypted-secret', }); const result = await authService.login( activeUser.email, 'correct-password', ); if (!result.mfaRequired) { throw new Error('expected mfaRequired to be true'); } expect(result.mfaToken).toBe('mfa-challenge-token'); expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id); expect(prisma.refreshToken.create).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN' }), ); }); it('records a LOGIN_FAILED audit entry on wrong password', async () => { usersService.findByEmail.mockResolvedValue(activeUser); await expect( authService.login(activeUser.email, 'wrong-password'), ).rejects.toBeInstanceOf(UnauthorizedException); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN_FAILED', userId: activeUser.id, }), ); }); it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => { usersService.findByEmail.mockResolvedValue(null); await expect( authService.login('missing@example.com', 'x'), ).rejects.toBeInstanceOf(UnauthorizedException); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN_FAILED', entityId: 'missing@example.com', }), ); const [[recordArg]] = auditLog.record.mock.calls as [ [{ userId?: string }], ]; expect(recordArg).not.toHaveProperty('userId'); }); }); describe('verifyMfaAndLogin', () => { const mfaUser = { ...activeUser, mfaEnabled: true, mfaSecret: 'encrypted-secret', }; it('issues tokens and records LOGIN when the code is valid', async () => { mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); usersService.findById.mockResolvedValue(mfaUser); mfaService.verifyCode.mockResolvedValue(true); prisma.refreshToken.create.mockResolvedValue({}); const result = await authService.verifyMfaAndLogin( 'mfa-challenge-token', '123456', ); expect(result.accessToken).toBe('signed.jwt.token'); expect(mfaService.verifyCode).toHaveBeenCalledWith( mfaUser.mfaSecret, mfaUser.email, '123456', ); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }), ); }); it('throws and records LOGIN_FAILED when the code is invalid', async () => { mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); usersService.findById.mockResolvedValue(mfaUser); mfaService.verifyCode.mockResolvedValue(false); await expect( authService.verifyMfaAndLogin('mfa-challenge-token', '000000'), ).rejects.toBeInstanceOf(UnauthorizedException); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN_FAILED', userId: mfaUser.id, }), ); expect(prisma.refreshToken.create).not.toHaveBeenCalled(); }); it('propagates an expired/invalid challenge token as UnauthorizedException', async () => { mfaService.verifyChallengeToken.mockImplementation(() => { throw new UnauthorizedException('expired'); }); await expect( authService.verifyMfaAndLogin('bad-token', '123456'), ).rejects.toBeInstanceOf(UnauthorizedException); expect(usersService.findById).not.toHaveBeenCalled(); }); }); describe('issueTokensForSsoUser', () => { it('issues tokens and records LOGIN for an active user', async () => { usersService.findById.mockResolvedValue(activeUser); prisma.refreshToken.create.mockResolvedValue({}); const result = await authService.issueTokensForSsoUser(activeUser.id); if (result.mfaRequired) { throw new Error('expected mfaRequired to be false'); } expect(result.accessToken).toBe('signed.jwt.token'); expect(prisma.refreshToken.create).toHaveBeenCalled(); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }), ); }); it('returns an MFA challenge instead of tokens when the SSO user has local MFA enabled', async () => { usersService.findById.mockResolvedValue({ ...activeUser, mfaEnabled: true, mfaSecret: 'encrypted-secret', }); const result = await authService.issueTokensForSsoUser(activeUser.id); if (!result.mfaRequired) { throw new Error('expected mfaRequired to be true'); } expect(result.mfaToken).toBe('mfa-challenge-token'); expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id); expect(prisma.refreshToken.create).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGIN' }), ); }); it('throws when the user is deactivated', async () => { usersService.findById.mockResolvedValue({ ...activeUser, isActive: false, }); await expect( authService.issueTokensForSsoUser(activeUser.id), ).rejects.toBeInstanceOf(UnauthorizedException); expect(prisma.refreshToken.create).not.toHaveBeenCalled(); }); }); describe('refresh', () => { it('throws when the refresh token is unknown', async () => { prisma.refreshToken.findUnique.mockResolvedValue(null); await expect(authService.refresh('unknown-token')).rejects.toBeInstanceOf( UnauthorizedException, ); }); it('throws when the refresh token is revoked and revokes all other sessions for that user (reuse detection)', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: new Date(), expiresAt: new Date(Date.now() + 1000), }); prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 }); await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf( UnauthorizedException, ); expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: activeUser.id, revokedAt: null }, }), ); expect(permissionResolver.invalidateUser).toHaveBeenCalledWith( activeUser.id, ); }); it('throws when the refresh token is expired', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: null, expiresAt: new Date(Date.now() - 1000), }); await expect(authService.refresh('expired-token')).rejects.toBeInstanceOf( UnauthorizedException, ); }); it('rotates the refresh token on success', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: null, expiresAt: new Date(Date.now() + 1000), }); usersService.findById.mockResolvedValue(activeUser); prisma.refreshToken.update.mockResolvedValue({}); prisma.refreshToken.create.mockResolvedValue({}); const result = await authService.refresh('valid-token'); expect(result.accessToken).toBe('signed.jwt.token'); expect(prisma.refreshToken.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'rt-1' } }), ); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'TOKEN_REFRESHED', userId: activeUser.id, }), ); }); }); describe('logout', () => { it('revokes the matching refresh token and records a LOGOUT audit entry', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: null, }); prisma.refreshToken.update.mockResolvedValue({}); await authService.logout('some-token'); expect(prisma.refreshToken.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'rt-1' } }), ); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'LOGOUT', userId: activeUser.id }), ); }); it('does nothing when the token is unknown or already revoked', async () => { prisma.refreshToken.findUnique.mockResolvedValue(null); await authService.logout('unknown-token'); expect(prisma.refreshToken.update).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalled(); }); }); });