From 7b0873e0da9a528ece051e1c9e8b682c262343d5 Mon Sep 17 00:00:00 2001 From: yosaphatprs Date: Tue, 2 Dec 2025 13:19:03 +0700 Subject: [PATCH] test: add unit test for auth modules. feat: add logout endpoint. fix: change auth register logic, add env variable to easily change token expire, fix bug for empty array and header csrf doesn't authenticate --- .../src/modules/auth/auth.controller.spec.ts | 180 +++++++++++ .../api/src/modules/auth/auth.controller.ts | 20 +- backend/api/src/modules/auth/auth.module.ts | 11 +- .../api/src/modules/auth/auth.service.spec.ts | 283 +++++++++++++++++- backend/api/src/modules/auth/auth.service.ts | 33 +- backend/api/src/modules/auth/dto/auth.dto.ts | 16 - .../src/modules/auth/guard/auth.guard.spec.ts | 152 +++++++++- .../api/src/modules/auth/guard/auth.guard.ts | 19 +- .../modules/auth/guard/roles.guard.spec.ts | 151 +++++++++- .../api/src/modules/auth/guard/roles.guard.ts | 6 +- .../auth/guard/websocket.guard.spec.ts | 144 ++++++++- .../src/modules/auth/guard/websocket.guard.ts | 2 +- .../src/components/dashboard/Sidebar.vue | 10 +- 13 files changed, 978 insertions(+), 49 deletions(-) diff --git a/backend/api/src/modules/auth/auth.controller.spec.ts b/backend/api/src/modules/auth/auth.controller.spec.ts index 27a31e6..0e2689e 100644 --- a/backend/api/src/modules/auth/auth.controller.spec.ts +++ b/backend/api/src/modules/auth/auth.controller.spec.ts @@ -1,18 +1,198 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { UserRole } from './dto/auth.dto'; describe('AuthController', () => { let controller: AuthController; + let authService: jest.Mocked; + let configService: jest.Mocked; + + const mockAuthService = { + registerUser: jest.fn(), + signIn: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const mockJwtService = { + signAsync: jest.fn(), + verifyAsync: jest.fn(), + }; beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: JwtService, useValue: mockJwtService }, + ], }).compile(); controller = module.get(AuthController); + authService = module.get(AuthService); + configService = module.get(ConfigService); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('registerUser', () => { + const createUserDto = { + nama_lengkap: 'Test User', + username: 'testuser', + password: 'password123', + role: UserRole.User, + }; + + const expectedResponse = { + id: BigInt(1), + nama_lengkap: 'Test User', + username: 'testuser', + role: UserRole.User, + }; + + it('should register a new user', async () => { + mockAuthService.registerUser.mockResolvedValue(expectedResponse); + + const result = await controller.registerUser(createUserDto); + + expect(result).toEqual(expectedResponse); + expect(mockAuthService.registerUser).toHaveBeenCalledWith(createUserDto); + expect(mockAuthService.registerUser).toHaveBeenCalledTimes(1); + }); + + it('should propagate service errors', async () => { + const error = new Error('Service error'); + mockAuthService.registerUser.mockRejectedValue(error); + + await expect(controller.registerUser(createUserDto)).rejects.toThrow( + 'Service error', + ); + }); + }); + + describe('login', () => { + const loginDto = { + username: 'testuser', + password: 'password123', + }; + + const mockSignInResponse = { + accessToken: 'jwt-token', + csrfToken: 'csrf-token', + user: { + id: BigInt(1), + username: 'testuser', + role: 'user', + }, + }; + + it('should login user and set cookie in development mode', async () => { + mockAuthService.signIn.mockResolvedValue(mockSignInResponse); + mockConfigService.get.mockReturnValue('development'); + + const mockResponse = { + cookie: jest.fn(), + }; + + const result = await controller.login(loginDto, mockResponse as any); + + expect(result).toEqual({ + user: mockSignInResponse.user, + csrfToken: mockSignInResponse.csrfToken, + }); + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'access_token', + 'jwt-token', + { + httpOnly: true, + secure: false, // development mode + sameSite: 'strict', + maxAge: 3600000, + }, + ); + }); + + it('should login user and set secure cookie in production mode', async () => { + mockAuthService.signIn.mockResolvedValue(mockSignInResponse); + mockConfigService.get.mockReturnValue('production'); + + const mockResponse = { + cookie: jest.fn(), + }; + + await controller.login(loginDto, mockResponse as any); + + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'access_token', + 'jwt-token', + { + httpOnly: true, + secure: true, // production mode + sameSite: 'strict', + maxAge: 3600000, + }, + ); + }); + + it('should propagate authentication errors', async () => { + mockAuthService.signIn.mockRejectedValue( + new Error('Invalid credentials'), + ); + + const mockResponse = { + cookie: jest.fn(), + }; + + await expect( + controller.login(loginDto, mockResponse as any), + ).rejects.toThrow('Invalid credentials'); + expect(mockResponse.cookie).not.toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('should clear access_token cookie in development mode', () => { + mockConfigService.get.mockReturnValue('development'); + + const mockResponse = { + clearCookie: jest.fn(), + }; + + const result = controller.logout(mockResponse as any); + + expect(result).toEqual({ message: 'Logout berhasil' }); + expect(mockResponse.clearCookie).toHaveBeenCalledWith('access_token', { + httpOnly: true, + secure: false, + sameSite: 'strict', + }); + }); + + it('should clear access_token cookie with secure flag in production mode', () => { + mockConfigService.get.mockReturnValue('production'); + + const mockResponse = { + clearCookie: jest.fn(), + }; + + const result = controller.logout(mockResponse as any); + + expect(result).toEqual({ message: 'Logout berhasil' }); + expect(mockResponse.clearCookie).toHaveBeenCalledWith('access_token', { + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + }); + }); }); diff --git a/backend/api/src/modules/auth/auth.controller.ts b/backend/api/src/modules/auth/auth.controller.ts index f1d52ca..72b2948 100644 --- a/backend/api/src/modules/auth/auth.controller.ts +++ b/backend/api/src/modules/auth/auth.controller.ts @@ -9,7 +9,7 @@ import { } from '@nestjs/common'; import type { Response } from 'express'; import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto'; -import { AuthDto, AuthDtoResponse, UserRole } from './dto/auth.dto'; +import { AuthDto, UserRole } from './dto/auth.dto'; import { AuthService } from './auth.service'; import { AuthGuard } from './guard/auth.guard'; import { RolesGuard } from './guard/roles.guard'; @@ -24,7 +24,6 @@ export class AuthController { ) {} @Post('/register') - @Header('Content-Type', 'application/json') @HttpCode(201) @UseGuards(AuthGuard, RolesGuard) @Roles(UserRole.Admin) @@ -46,9 +45,24 @@ export class AuthController { httpOnly: true, secure: this.configService.get('NODE_ENV') !== 'development', sameSite: 'strict', - maxAge: 3600000, + maxAge: parseInt( + this.configService.get('COOKIE_MAX_AGE') || '7200000', + 10, + ), }); return { user, csrfToken }; } + + @Post('logout') + @HttpCode(200) + logout(@Res({ passthrough: true }) res: Response) { + res.clearCookie('access_token', { + httpOnly: true, + secure: this.configService.get('NODE_ENV') !== 'development', + sameSite: 'strict', + }); + + return { message: 'Logout berhasil' }; + } } diff --git a/backend/api/src/modules/auth/auth.module.ts b/backend/api/src/modules/auth/auth.module.ts index 14378f7..ae33117 100644 --- a/backend/api/src/modules/auth/auth.module.ts +++ b/backend/api/src/modules/auth/auth.module.ts @@ -3,7 +3,8 @@ import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { PrismaModule } from '../prisma/prisma.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { JwtModule } from '@nestjs/jwt'; +import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; +import type { StringValue } from 'ms'; @Module({ exports: [AuthService], @@ -14,9 +15,13 @@ import { JwtModule } from '@nestjs/jwt'; global: true, imports: [ConfigModule], inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ + useFactory: (configService: ConfigService): JwtModuleOptions => ({ secret: configService.get('JWT_SECRET'), - signOptions: { expiresIn: '120m' }, + signOptions: { + expiresIn: + (configService.get('JWT_EXPIRES_IN') as StringValue) ?? + '120m', + }, }), }), ], diff --git a/backend/api/src/modules/auth/auth.service.spec.ts b/backend/api/src/modules/auth/auth.service.spec.ts index 800ab66..17c86fb 100644 --- a/backend/api/src/modules/auth/auth.service.spec.ts +++ b/backend/api/src/modules/auth/auth.service.spec.ts @@ -1,12 +1,47 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { Prisma } from '@dist/generated/prisma'; +import * as bcrypt from 'bcrypt'; +import { UserRole } from './dto/auth.dto'; + +// Mock bcrypt +jest.mock('bcrypt', () => ({ + hash: jest.fn(), + compare: jest.fn(), +})); describe('AuthService', () => { let service: AuthService; + const mockPrisma = { + users: { + create: jest.fn(), + findUnique: jest.fn(), + }, + }; + + const mockJwtService = { + signAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], + providers: [ + AuthService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: JwtService, useValue: mockJwtService }, + { provide: ConfigService, useValue: mockConfigService }, + ], }).compile(); service = module.get(AuthService); @@ -15,4 +50,250 @@ describe('AuthService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('registerUser', () => { + const createUserDto = { + nama_lengkap: 'Test User', + username: 'testuser', + password: 'password123', + role: UserRole.User, + }; + + const createdUser = { + id: BigInt(1), + nama_lengkap: 'Test User', + username: 'testuser', + password_hash: 'hashedPassword', + role: 'user', + created_at: new Date(), + updated_at: new Date(), + }; + + it('should register a new user successfully', async () => { + mockConfigService.get.mockReturnValue(10); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + mockPrisma.users.create.mockResolvedValue(createdUser); + + const result = await service.registerUser(createUserDto); + + expect(result).toEqual({ + id: BigInt(1), + nama_lengkap: 'Test User', + username: 'testuser', + role: 'user', + }); + expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10); + expect(mockPrisma.users.create).toHaveBeenCalledWith({ + data: { + nama_lengkap: 'Test User', + username: 'testuser', + password_hash: 'hashedPassword', + role: 'user', + }, + }); + }); + + /** + * BUG TEST: This test SHOULD PASS but will FAIL + * + * Problem: configService.get() returns STRING from .env, not number + * Current code: configService.get('BCRYPT_SALT') ?? 10 + * + * When BCRYPT_SALT=10 is in .env, it returns '10' (string), not 10 (number) + * bcrypt.hash receives '10' instead of 10 + * + * Fix needed in auth.service.ts: + * const salt = parseInt(this.configService.get('BCRYPT_SALT') || '10', 10); + */ + it('should pass NUMBER salt to bcrypt.hash (not string)', async () => { + // Simulate real .env behavior: returns string '10' + mockConfigService.get.mockReturnValue('10'); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + mockPrisma.users.create.mockResolvedValue(createdUser); + + await service.registerUser(createUserDto); + + // CORRECT expectation: salt should be NUMBER 10, not STRING '10' + expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10); + }); + + it('should use default salt value 10 when BCRYPT_SALT is not configured', async () => { + mockConfigService.get.mockReturnValue(undefined); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + mockPrisma.users.create.mockResolvedValue(createdUser); + + await service.registerUser(createUserDto); + + expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10); + }); + + it('should default role to "user" when not provided', async () => { + const dtoWithoutRole = { + nama_lengkap: 'Test User', + username: 'testuser', + password: 'password123', + }; + + mockConfigService.get.mockReturnValue(10); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + mockPrisma.users.create.mockResolvedValue(createdUser); + + await service.registerUser(dtoWithoutRole as any); + + expect(mockPrisma.users.create).toHaveBeenCalledWith({ + data: { + nama_lengkap: 'Test User', + username: 'testuser', + password_hash: 'hashedPassword', + role: 'user', + }, + }); + }); + + it('should throw ConflictException when username already exists (P2002)', async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed', + { code: 'P2002', clientVersion: '5.0.0' }, + ); + + mockConfigService.get.mockReturnValue(10); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + mockPrisma.users.create.mockRejectedValue(prismaError); + + await expect(service.registerUser(createUserDto)).rejects.toThrow( + ConflictException, + ); + }); + + it('should rethrow non-P2002 Prisma errors', async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError( + 'Foreign key constraint failed', + { code: 'P2003', clientVersion: '5.0.0' }, + ); + + mockConfigService.get.mockReturnValue(10); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + mockPrisma.users.create.mockRejectedValue(prismaError); + + await expect(service.registerUser(createUserDto)).rejects.toThrow( + Prisma.PrismaClientKnownRequestError, + ); + }); + + it('should rethrow unknown errors without wrapping', async () => { + const unknownError = new Error('Database connection failed'); + + mockConfigService.get.mockReturnValue(10); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + mockPrisma.users.create.mockRejectedValue(unknownError); + + await expect(service.registerUser(createUserDto)).rejects.toThrow( + 'Database connection failed', + ); + }); + }); + + describe('signIn', () => { + const mockUser = { + id: BigInt(1), + nama_lengkap: 'Test User', + username: 'testuser', + password_hash: 'hashedPassword', + role: 'user', + created_at: new Date(), + updated_at: new Date(), + }; + + it('should sign in user successfully and return tokens', async () => { + mockPrisma.users.findUnique.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + mockJwtService.signAsync.mockResolvedValue('jwt-token'); + + const result = await service.signIn('testuser', 'password123'); + + expect(result).toHaveProperty('accessToken', 'jwt-token'); + expect(result).toHaveProperty('csrfToken'); + expect(result.csrfToken).toHaveLength(64); + expect(result.user).toEqual({ + id: BigInt(1), + username: 'testuser', + role: 'user', + }); + }); + + it('should include csrf token in JWT payload', async () => { + mockPrisma.users.findUnique.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + mockJwtService.signAsync.mockResolvedValue('jwt-token'); + + await service.signIn('testuser', 'password123'); + + expect(mockJwtService.signAsync).toHaveBeenCalledWith( + expect.objectContaining({ + sub: BigInt(1), + username: 'testuser', + role: 'user', + csrf: expect.any(String), + }), + ); + }); + + it('should throw UnauthorizedException when user not found', async () => { + mockPrisma.users.findUnique.mockResolvedValue(null); + + await expect(service.signIn('nonexistent', 'password')).rejects.toThrow( + UnauthorizedException, + ); + expect(bcrypt.compare).not.toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException when password is incorrect', async () => { + mockPrisma.users.findUnique.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + await expect(service.signIn('testuser', 'wrongpassword')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should use same error response for user-not-found and wrong-password (security)', async () => { + mockPrisma.users.findUnique.mockResolvedValue(null); + + let errorForNonexistent: UnauthorizedException | undefined; + try { + await service.signIn('nonexistent', 'password'); + } catch (error) { + errorForNonexistent = error as UnauthorizedException; + } + + mockPrisma.users.findUnique.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + let errorForWrongPassword: UnauthorizedException | undefined; + try { + await service.signIn('testuser', 'wrongpassword'); + } catch (error) { + errorForWrongPassword = error as UnauthorizedException; + } + + expect(errorForNonexistent).toBeInstanceOf(UnauthorizedException); + expect(errorForWrongPassword).toBeInstanceOf(UnauthorizedException); + expect(errorForNonexistent?.getResponse()).toEqual( + errorForWrongPassword?.getResponse(), + ); + }); + + it('should call bcrypt.compare with correct arguments', async () => { + mockPrisma.users.findUnique.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + mockJwtService.signAsync.mockResolvedValue('jwt-token'); + + await service.signIn('testuser', 'mypassword'); + + expect(bcrypt.compare).toHaveBeenCalledWith( + 'mypassword', + 'hashedPassword', + ); + }); + }); }); diff --git a/backend/api/src/modules/auth/auth.service.ts b/backend/api/src/modules/auth/auth.service.ts index 8fb03f9..8f8ec48 100644 --- a/backend/api/src/modules/auth/auth.service.ts +++ b/backend/api/src/modules/auth/auth.service.ts @@ -2,9 +2,10 @@ import { PrismaService } from '@api/modules/prisma/prisma.service'; import { ConflictException, Injectable, + InternalServerErrorException, UnauthorizedException, } from '@nestjs/common'; -import { AuthDtoResponse, UserRole } from './dto/auth.dto'; +import { UserRole } from './dto/auth.dto'; import * as bcrypt from 'bcrypt'; import { JwtService } from '@nestjs/jwt'; import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto'; @@ -20,8 +21,28 @@ export class AuthService { private configService: ConfigService, ) {} + async isUserExisting(username: string): Promise { + let user; + try { + user = await this.prisma.users.findUnique({ + where: { username }, + }); + } catch (error) { + console.error('Error checking if user exists:', error); + user = null; + throw new InternalServerErrorException(); + } + return !!user; + } + async registerUser(data: CreateUserDto): Promise { - const salt = this.configService.get('BCRYPT_SALT') ?? 10; + const saltEnv = this.configService.get('BCRYPT_SALT'); + const salt = saltEnv ? parseInt(saltEnv, 10) : 10; + + if (await this.isUserExisting(data.username)) { + throw new ConflictException('Username ini sudah terdaftar'); + } + const hashedPassword = await bcrypt.hash(data.password, salt); try { @@ -41,12 +62,8 @@ export class AuthService { role: userCreated.role as UserRole, }; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === 'P2002') { - throw new ConflictException('Username ini sudah terdaftar'); - } - } - throw error; + console.error('Error registering user:', error); + throw new InternalServerErrorException(); } } diff --git a/backend/api/src/modules/auth/dto/auth.dto.ts b/backend/api/src/modules/auth/dto/auth.dto.ts index 28b2173..bc29e51 100644 --- a/backend/api/src/modules/auth/dto/auth.dto.ts +++ b/backend/api/src/modules/auth/dto/auth.dto.ts @@ -17,19 +17,3 @@ export class AuthDto { @Length(6, undefined, { message: 'Password minimal 6 karakter' }) password: string; } - -export class AuthDtoResponse { - @Expose() - @Transform(({ value }: { value: bigint }) => value.toString()) - id: bigint; - - @Expose() - username: string; - - @Expose() - @IsEnum(UserRole) - role: UserRole; - - @Expose() - token: string; -} diff --git a/backend/api/src/modules/auth/guard/auth.guard.spec.ts b/backend/api/src/modules/auth/guard/auth.guard.spec.ts index b1496ee..a8d50d0 100644 --- a/backend/api/src/modules/auth/guard/auth.guard.spec.ts +++ b/backend/api/src/modules/auth/guard/auth.guard.spec.ts @@ -1,9 +1,157 @@ -import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; import { AuthGuard } from './auth.guard'; +import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; describe('AuthGuard', () => { + let guard: AuthGuard; + + const mockJwtService = { + verifyAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthGuard, + { provide: JwtService, useValue: mockJwtService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + guard = module.get(AuthGuard); + }); + + const createMockExecutionContext = ( + cookies?: any, + headers?: any, + ): ExecutionContext => { + const mockRequest = { + cookies: cookies, + headers: headers || {}, + }; + + return { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as ExecutionContext; + }; + it('should be defined', () => { - expect(new AuthGuard(new JwtService(), new ConfigService())).toBeDefined(); + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + it('should return true when JWT and CSRF token are valid and match', async () => { + const csrfToken = 'valid-csrf-token'; + const payload = { + sub: 1, + username: 'testuser', + role: 'user', + csrf: csrfToken, + }; + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockResolvedValue(payload); + + const context = createMockExecutionContext( + { access_token: 'valid-jwt-token' }, + { 'x-csrf-token': csrfToken }, + ); + const request = context.switchToHttp().getRequest(); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(request['user']).toEqual(payload); + }); + + it('should throw UnauthorizedException when CSRF token is missing', async () => { + const context = createMockExecutionContext( + { access_token: 'valid-jwt-token' }, + {}, // no CSRF header + ); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockJwtService.verifyAsync).not.toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException when CSRF token does not match JWT payload', async () => { + const payload = { + sub: 1, + username: 'testuser', + role: 'user', + csrf: 'correct-csrf', + }; + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockResolvedValue(payload); + + const context = createMockExecutionContext( + { access_token: 'valid-jwt-token' }, + { 'x-csrf-token': 'wrong-csrf-token' }, // doesn't match + ); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when no cookies present', async () => { + const context = createMockExecutionContext(undefined, { + 'x-csrf-token': 'some-csrf', + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when access_token cookie is missing', async () => { + const context = createMockExecutionContext( + { other_cookie: 'value' }, + { 'x-csrf-token': 'some-csrf' }, + ); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when JWT verification fails', async () => { + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token')); + + const context = createMockExecutionContext( + { access_token: 'invalid-token' }, + { 'x-csrf-token': 'some-csrf' }, + ); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when JWT is expired', async () => { + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockRejectedValue(new Error('jwt expired')); + + const context = createMockExecutionContext( + { access_token: 'expired-token' }, + { 'x-csrf-token': 'some-csrf' }, + ); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + }); }); }); diff --git a/backend/api/src/modules/auth/guard/auth.guard.ts b/backend/api/src/modules/auth/guard/auth.guard.ts index fdfec64..c82411a 100644 --- a/backend/api/src/modules/auth/guard/auth.guard.ts +++ b/backend/api/src/modules/auth/guard/auth.guard.ts @@ -17,14 +17,23 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const token = this.extractTokenFromCookie(request); - if (!token) { + const jwtToken = this.extractTokenFromCookie(request); + const csrfToken = this.extractTokenFromHeader(request); + + if (!jwtToken || !csrfToken) { throw new UnauthorizedException(); } + try { - const payload = await this.jwtService.verifyAsync(token, { + const payload = await this.jwtService.verifyAsync(jwtToken, { secret: this.configService.get('JWT_SECRET'), }); + console.log(payload); + + if (payload.csrf !== csrfToken) { + throw new UnauthorizedException(['Invalid CSRF token']); + } + request['user'] = payload; } catch { throw new UnauthorizedException(); @@ -33,8 +42,8 @@ export class AuthGuard implements CanActivate { } private extractTokenFromHeader(request: any): string | undefined { - const [type, token] = request.headers?.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; + const token = request.headers['x-csrf-token']; + return token; } private extractTokenFromCookie(request: Request): string | undefined { diff --git a/backend/api/src/modules/auth/guard/roles.guard.spec.ts b/backend/api/src/modules/auth/guard/roles.guard.spec.ts index a59dbd6..a682644 100644 --- a/backend/api/src/modules/auth/guard/roles.guard.spec.ts +++ b/backend/api/src/modules/auth/guard/roles.guard.spec.ts @@ -1,8 +1,155 @@ -import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; import { RolesGuard } from './roles.guard'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { UserRole } from '../dto/auth.dto'; describe('RolesGuard', () => { + let guard: RolesGuard; + + const mockReflector = { + getAllAndOverride: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [RolesGuard, { provide: Reflector, useValue: mockReflector }], + }).compile(); + + guard = module.get(RolesGuard); + }); + + const createMockExecutionContext = (user?: any): ExecutionContext => { + const mockRequest = { user }; + + return { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as unknown as ExecutionContext; + }; + it('should be defined', () => { - expect(new RolesGuard(new Reflector())).toBeDefined(); + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + it('should return true when no roles are required (undefined)', () => { + mockReflector.getAllAndOverride.mockReturnValue(undefined); + + const context = createMockExecutionContext({ role: 'user' }); + const result = guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should return true when no roles are required (null)', () => { + mockReflector.getAllAndOverride.mockReturnValue(null); + + const context = createMockExecutionContext({ role: 'user' }); + const result = guard.canActivate(context); + + expect(result).toBe(true); + }); + + /** + * BUG TEST: This test SHOULD PASS but will FAIL + * + * Problem: Empty array [] is truthy in JavaScript + * Current code: if (!requiredRoles) { return true; } + * + * When @Roles() is used without arguments, requiredRoles = [] + * [] is truthy, so ![] is false, so the early return doesn't happen + * Then .some([]) returns false, causing ForbiddenException + * + * Fix needed in roles.guard.ts: + * if (!requiredRoles || requiredRoles.length === 0) { return true; } + */ + it('should return true when roles array is empty (no restrictions)', () => { + mockReflector.getAllAndOverride.mockReturnValue([]); + + const context = createMockExecutionContext({ role: 'user' }); + + // CORRECT expectation: empty roles = no restrictions = allow access + const result = guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should return true when user has required role', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]); + + const context = createMockExecutionContext({ role: UserRole.Admin }); + const result = guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should return true when user has one of multiple required roles', () => { + mockReflector.getAllAndOverride.mockReturnValue([ + UserRole.Admin, + UserRole.User, + ]); + + const context = createMockExecutionContext({ role: UserRole.User }); + const result = guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should throw ForbiddenException when user object has no role property', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]); + + const context = createMockExecutionContext({}); + + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + expect(() => guard.canActivate(context)).toThrow( + 'Insufficient permissions (no role)', + ); + }); + + it('should throw ForbiddenException when user is undefined', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]); + + const context = createMockExecutionContext(undefined); + + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it('should throw ForbiddenException when user does not have required role', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]); + + const context = createMockExecutionContext({ role: UserRole.User }); + + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + expect(() => guard.canActivate(context)).toThrow( + 'You do not have the required role', + ); + }); + + it('should throw ForbiddenException when user role is null', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]); + + const context = createMockExecutionContext({ role: null }); + + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + expect(() => guard.canActivate(context)).toThrow( + 'Insufficient permissions (no role)', + ); + }); + + it('should throw ForbiddenException when user role is empty string', () => { + mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]); + + const context = createMockExecutionContext({ role: '' }); + + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + expect(() => guard.canActivate(context)).toThrow( + 'Insufficient permissions (no role)', + ); + }); }); }); diff --git a/backend/api/src/modules/auth/guard/roles.guard.ts b/backend/api/src/modules/auth/guard/roles.guard.ts index 12d8e3f..e7583e0 100644 --- a/backend/api/src/modules/auth/guard/roles.guard.ts +++ b/backend/api/src/modules/auth/guard/roles.guard.ts @@ -18,14 +18,14 @@ export class RolesGuard implements CanActivate { [context.getHandler(), context.getClass()], ); - if (!requiredRoles) { + if (!requiredRoles || requiredRoles.length === 0) { return true; } const { user } = context.switchToHttp().getRequest(); if (!user?.role) { - throw new ForbiddenException('Insufficient permissions (no role)'); + throw new ForbiddenException(['Insufficient permissions (no role)']); } const hasRole = requiredRoles.some((role) => user.role === role); @@ -34,6 +34,6 @@ export class RolesGuard implements CanActivate { return true; } - throw new ForbiddenException('You do not have the required role'); + throw new ForbiddenException(['You do not have the required role']); } } diff --git a/backend/api/src/modules/auth/guard/websocket.guard.spec.ts b/backend/api/src/modules/auth/guard/websocket.guard.spec.ts index 12d0896..b5d199e 100644 --- a/backend/api/src/modules/auth/guard/websocket.guard.spec.ts +++ b/backend/api/src/modules/auth/guard/websocket.guard.spec.ts @@ -1,11 +1,147 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WebsocketGuard } from './websocket.guard'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { WebsocketGuard } from './websocket.guard'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Socket } from 'socket.io'; describe('WebsocketGuard', () => { + let guard: WebsocketGuard; + let jwtService: jest.Mocked; + let configService: jest.Mocked; + + const mockJwtService = { + verifyAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebsocketGuard, + { provide: JwtService, useValue: mockJwtService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + guard = module.get(WebsocketGuard); + jwtService = module.get(JwtService); + configService = module.get(ConfigService); + }); + + const createMockSocket = (cookieHeader?: string): Partial => ({ + handshake: { + headers: { + cookie: cookieHeader, + }, + } as any, + data: {}, + disconnect: jest.fn(), + }); + + const createMockExecutionContext = ( + socket: Partial, + ): ExecutionContext => { + return { + switchToWs: () => ({ + getClient: () => socket, + }), + } as unknown as ExecutionContext; + }; + it('should be defined', () => { - expect( - new WebsocketGuard(new JwtService(), new ConfigService()), - ).toBeDefined(); + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + it('should return true and attach user to socket data when token is valid', async () => { + const payload = { sub: 1, username: 'testuser', role: 'user' }; + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockResolvedValue(payload); + + const socket = createMockSocket('access_token=valid-token'); + const context = createMockExecutionContext(socket); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(socket.data?.user).toEqual(payload); + expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token', { + secret: 'jwt-secret', + }); + }); + + it('should disconnect and throw UnauthorizedException when no cookie header', async () => { + const socket = createMockSocket(undefined); + const context = createMockExecutionContext(socket); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + await expect(guard.canActivate(context)).rejects.toThrow( + 'No token provided', + ); + expect(socket.disconnect).toHaveBeenCalled(); + }); + + it('should disconnect and throw UnauthorizedException when access_token not in cookies', async () => { + const socket = createMockSocket('other_cookie=value'); + const context = createMockExecutionContext(socket); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + expect(socket.disconnect).toHaveBeenCalled(); + }); + + it('should disconnect and throw UnauthorizedException when token is invalid', async () => { + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token')); + + const socket = createMockSocket('access_token=invalid-token'); + const context = createMockExecutionContext(socket); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + await expect(guard.canActivate(context)).rejects.toThrow('Invalid token'); + expect(socket.disconnect).toHaveBeenCalled(); + }); + + it('should handle multiple cookies and extract access_token correctly', async () => { + const payload = { sub: 1, username: 'testuser', role: 'user' }; + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockResolvedValue(payload); + + const socket = createMockSocket( + 'session=abc123; access_token=valid-token; other=value', + ); + const context = createMockExecutionContext(socket); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token', { + secret: 'jwt-secret', + }); + }); + + it('should disconnect and throw when token is expired', async () => { + mockConfigService.get.mockReturnValue('jwt-secret'); + mockJwtService.verifyAsync.mockRejectedValue(new Error('jwt expired')); + + const socket = createMockSocket('access_token=expired-token'); + const context = createMockExecutionContext(socket); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + expect(socket.disconnect).toHaveBeenCalled(); + }); }); }); diff --git a/backend/api/src/modules/auth/guard/websocket.guard.ts b/backend/api/src/modules/auth/guard/websocket.guard.ts index 8372fac..bc58c37 100644 --- a/backend/api/src/modules/auth/guard/websocket.guard.ts +++ b/backend/api/src/modules/auth/guard/websocket.guard.ts @@ -10,7 +10,7 @@ import { Socket } from 'socket.io'; import * as cookie from 'cookie'; interface AuthPayload { - sub: number; + sub: bigint; username: string; role: string; } diff --git a/frontend/hospital-log/src/components/dashboard/Sidebar.vue b/frontend/hospital-log/src/components/dashboard/Sidebar.vue index f550c40..1e3bd9b 100644 --- a/frontend/hospital-log/src/components/dashboard/Sidebar.vue +++ b/frontend/hospital-log/src/components/dashboard/Sidebar.vue @@ -2,16 +2,24 @@ import { ref } from "vue"; import { useRouter, useRoute } from "vue-router"; import DialogConfirm from "../DialogConfirm.vue"; +import { useApi } from "../../composables/useApi"; const router = useRouter(); const route = useRoute(); +const { post } = useApi(); const logoutDialog = ref | null>(null); const showLogoutDialog = () => { logoutDialog.value?.show(); }; -const handleLogoutConfirm = () => { +const handleLogoutConfirm = async () => { + try { + await post("/auth/logout", {}); + } catch (error) { + console.error("Logout error:", error); + } + localStorage.removeItem("csrf_token"); router.push({ name: "login" }); };