diff --git a/package-lock.json b/package-lock.json index 8509ef75..46c146ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4851,6 +4851,24 @@ } } }, + "node_modules/@nestjs/schematics/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -4881,6 +4899,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nestjs/schematics/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 7cf53035..95feb6b4 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -134,6 +134,20 @@ describe('AuthService', () => { expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null }); }); + it('throws UnauthorizedException when the user status is SUSPENDED', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(makeUser({ status: UserStatus.SUSPENDED })); + + await expect(service.refreshTokens('token')).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when the user status is INACTIVE', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(makeUser({ status: UserStatus.INACTIVE })); + + await expect(service.refreshTokens('token')).rejects.toThrow(UnauthorizedException); + }); + it('issues new tokens when the refresh token is valid and not blacklisted', async () => { mockJwtService.verify.mockReturnValue(validDecoded); mockUserRepo.findOneBy.mockResolvedValue(makeUser()); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3abeafaf..c7f75826 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import * as bcrypt from 'bcrypt'; -import { User } from '../users/entities/user.entity'; +import { User, UserStatus } from '../users/entities/user.entity'; import { TokenBlacklistService } from './services/token-blacklist.service'; @Injectable() @@ -51,6 +51,10 @@ export class AuthService { throw new UnauthorizedException('Access Denied'); } + if (user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('User is not active'); + } + const refreshTokenMatches = await bcrypt.compare(refreshToken, user.refreshToken); if (!refreshTokenMatches) { throw new UnauthorizedException('Access Denied'); diff --git a/src/auth/jwt.strategy.spec.ts b/src/auth/jwt.strategy.spec.ts new file mode 100644 index 00000000..39effd29 --- /dev/null +++ b/src/auth/jwt.strategy.spec.ts @@ -0,0 +1,98 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { UnauthorizedException } from '@nestjs/common'; +import { JwtStrategy, JwtPayload } from './jwt.strategy'; +import { User, UserStatus } from '../users/entities/user.entity'; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + + const mockUserRepo = { + findOneBy: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [JwtStrategy, { provide: getRepositoryToken(User), useValue: mockUserRepo }], + }).compile(); + + strategy = module.get(JwtStrategy); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(strategy).toBeDefined(); + }); + + describe('validate', () => { + const payload: JwtPayload = { + sub: 'user-1', + email: 'test@example.com', + roles: [], + permissions: [], + }; + + const mockUser = { + id: 'user-1', + email: 'test@example.com', + status: UserStatus.ACTIVE, + }; + + const mockUserWithRolesAndPermissions = { + ...mockUser, + roles: [ + { + name: 'student', + permissions: [{ resource: 'course', action: 'read' }], + }, + ], + }; + + it('should successfully validate and return payload with roles and permissions if user is active', async () => { + mockUserRepo.findOneBy.mockResolvedValue(mockUser); + + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(mockUserWithRolesAndPermissions), + }; + mockUserRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await strategy.validate(payload); + + expect(mockUserRepo.findOneBy).toHaveBeenCalledWith({ id: 'user-1' }); + expect(result).toEqual({ + sub: 'user-1', + email: 'test@example.com', + roles: ['student'], + permissions: ['course:read'], + }); + }); + + it('should throw UnauthorizedException if the user is suspended', async () => { + mockUserRepo.findOneBy.mockResolvedValue({ + ...mockUser, + status: UserStatus.SUSPENDED, + }); + + await expect(strategy.validate(payload)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException if the user is inactive', async () => { + mockUserRepo.findOneBy.mockResolvedValue({ + ...mockUser, + status: UserStatus.INACTIVE, + }); + + await expect(strategy.validate(payload)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw an error if the user is not found', async () => { + mockUserRepo.findOneBy.mockResolvedValue(null); + + await expect(strategy.validate(payload)).rejects.toThrow(Error); + }); + }); +}); diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index dae9b9e5..0f25ab40 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -1,9 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { User } from '../users/entities/user.entity'; +import { User, UserStatus } from '../users/entities/user.entity'; export interface JwtPayload { sub: string; @@ -39,6 +39,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { throw new Error('User not found'); } + if (user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('User is not active'); + } + // Fetch roles and permissions for the user const userWithRolesAndPermissions = await this.userRepository .createQueryBuilder('user')