From cf6a6cb465f754b3c57189f4a98e4e8fd0238ad2 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Wed, 24 Jun 2026 16:57:00 +0100 Subject: [PATCH 1/2] test(auth): add comprehensive unit tests for AuthService --- src/auth/auth.service.spec.ts | 152 ++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/auth/auth.service.spec.ts diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..7cf53035 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,152 @@ +jest.mock('bcrypt', () => ({ + genSalt: jest.fn().mockResolvedValue('salt'), + hash: jest.fn().mockResolvedValue('hashed-password'), + compare: jest.fn().mockResolvedValue(true), +})); + +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { UnauthorizedException } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { TokenBlacklistService } from './services/token-blacklist.service'; +import { User, UserStatus } from '../users/entities/user.entity'; + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + password: 'hashed', + firstName: 'Test', + lastName: 'User', + status: UserStatus.ACTIVE, + refreshToken: 'old-hash', + passwordHistory: [], + ...overrides, + } as User; +} + +const mockUserRepo = { + findOneBy: jest.fn(), + update: jest.fn(), +}; + +const mockJwtService = { + signAsync: jest.fn(), + verify: jest.fn(), +}; + +const mockBlacklistService = { + addToBlacklist: jest.fn(), + isBlacklisted: jest.fn(), +}; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: JwtService, useValue: mockJwtService }, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: TokenBlacklistService, useValue: mockBlacklistService }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('login', () => { + it('generates tokens and stores hashed refresh token', async () => { + mockJwtService.signAsync + .mockResolvedValueOnce('access-token') + .mockResolvedValueOnce('refresh-token'); + mockUserRepo.update.mockResolvedValue(undefined); + + const result = await service.login(makeUser()); + + expect(mockJwtService.signAsync).toHaveBeenCalledTimes(2); + expect(mockUserRepo.update).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ refreshToken: expect.any(String) }), + ); + expect(result).toEqual({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + }); + }); + }); + + describe('logout', () => { + it('clears the stored refresh token for the given user', async () => { + mockUserRepo.update.mockResolvedValue(undefined); + + await service.logout('user-1'); + + expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null }); + }); + }); + + describe('refreshTokens', () => { + const validDecoded = { + sub: 'user-1', + email: 'test@example.com', + jti: 'jti-abc', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + it('throws UnauthorizedException when the token cannot be verified', async () => { + mockJwtService.verify.mockImplementation(() => { + throw new Error('invalid signature'); + }); + + await expect(service.refreshTokens('bad-token')).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when the user does not exist', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(null); + + await expect(service.refreshTokens('token')).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when user has no stored refresh token', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(makeUser({ refreshToken: undefined })); + + await expect(service.refreshTokens('token')).rejects.toThrow(UnauthorizedException); + }); + + it('revokes all tokens and throws when a blacklisted token is reused', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(makeUser()); + mockBlacklistService.isBlacklisted.mockResolvedValue(true); + mockUserRepo.update.mockResolvedValue(undefined); + + await expect(service.refreshTokens('revoked-token')).rejects.toThrow(UnauthorizedException); + expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null }); + }); + + it('issues new tokens when the refresh token is valid and not blacklisted', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(makeUser()); + mockBlacklistService.isBlacklisted.mockResolvedValue(false); + mockBlacklistService.addToBlacklist.mockResolvedValue(undefined); + mockJwtService.signAsync + .mockResolvedValueOnce('new-access') + .mockResolvedValueOnce('new-refresh'); + mockUserRepo.update.mockResolvedValue(undefined); + + const result = await service.refreshTokens('valid-token'); + + expect(result).toEqual({ accessToken: 'new-access', refreshToken: 'new-refresh' }); + }); + }); +}); From 21bc9e9e29a23764fa161dd8347cda1c0451dd36 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Wed, 24 Jun 2026 16:57:14 +0100 Subject: [PATCH 2/2] test(auth): add unit tests for TokenBlacklistService --- .../services/token-blacklist.service.spec.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/auth/services/token-blacklist.service.spec.ts diff --git a/src/auth/services/token-blacklist.service.spec.ts b/src/auth/services/token-blacklist.service.spec.ts new file mode 100644 index 00000000..924a2b11 --- /dev/null +++ b/src/auth/services/token-blacklist.service.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TokenBlacklistService } from './token-blacklist.service'; +import { CachingService } from '../../caching/caching.service'; + +const mockCachingService = { + set: jest.fn(), + get: jest.fn(), +}; + +describe('TokenBlacklistService', () => { + let service: TokenBlacklistService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TokenBlacklistService, + { provide: CachingService, useValue: mockCachingService }, + ], + }).compile(); + + service = module.get(TokenBlacklistService); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('addToBlacklist', () => { + it('stores the token JTI in cache with TTL converted to seconds', async () => { + mockCachingService.set.mockResolvedValue(undefined); + + await service.addToBlacklist('jti-1', 60_000); + + expect(mockCachingService.set).toHaveBeenCalledWith('bl_token:jti-1', 'revoked', 60); + }); + + it('rounds fractional TTL up to the nearest whole second', async () => { + mockCachingService.set.mockResolvedValue(undefined); + + await service.addToBlacklist('jti-2', 1_500); + + expect(mockCachingService.set).toHaveBeenCalledWith('bl_token:jti-2', 'revoked', 2); + }); + + it('stores the correct cache key prefix', async () => { + mockCachingService.set.mockResolvedValue(undefined); + + await service.addToBlacklist('my-jti', 10_000); + + expect(mockCachingService.set).toHaveBeenCalledWith( + expect.stringContaining('bl_token:my-jti'), + 'revoked', + expect.any(Number), + ); + }); + }); + + describe('isBlacklisted', () => { + it('returns true when the cache entry equals "revoked"', async () => { + mockCachingService.get.mockResolvedValue('revoked'); + + const result = await service.isBlacklisted('jti-3'); + + expect(result).toBe(true); + expect(mockCachingService.get).toHaveBeenCalledWith('bl_token:jti-3'); + }); + + it('returns false when the cache entry does not exist', async () => { + mockCachingService.get.mockResolvedValue(null); + + const result = await service.isBlacklisted('jti-4'); + + expect(result).toBe(false); + }); + + it('returns false when the cache entry has an unexpected value', async () => { + mockCachingService.get.mockResolvedValue('expired'); + + const result = await service.isBlacklisted('jti-5'); + + expect(result).toBe(false); + }); + }); +});