Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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>(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' });
});
});
});
86 changes: 86 additions & 0 deletions src/auth/services/token-blacklist.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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: [

Check failure on line 15 in src/auth/services/token-blacklist.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎········TokenBlacklistService,⏎········{·provide:·CachingService,·useValue:·mockCachingService·},⏎······` with `TokenBlacklistService,·{·provide:·CachingService,·useValue:·mockCachingService·}`
TokenBlacklistService,
{ provide: CachingService, useValue: mockCachingService },
],
}).compile();

service = module.get<TokenBlacklistService>(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);
});
});
});
Loading