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

This commit is contained in:
yosaphatprs 2025-12-02 13:19:03 +07:00
parent 74d5da7475
commit 7b0873e0da
13 changed files with 978 additions and 49 deletions

View File

@ -1,18 +1,198 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller'; 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', () => { describe('AuthController', () => {
let controller: AuthController; let controller: AuthController;
let authService: jest.Mocked<AuthService>;
let configService: jest.Mocked<ConfigService>;
const mockAuthService = {
registerUser: jest.fn(),
signIn: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
verifyAsync: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController], controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile(); }).compile();
controller = module.get<AuthController>(AuthController); controller = module.get<AuthController>(AuthController);
authService = module.get(AuthService);
configService = module.get(ConfigService);
}); });
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); 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',
});
});
});
}); });

View File

@ -9,7 +9,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto'; 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 { AuthService } from './auth.service';
import { AuthGuard } from './guard/auth.guard'; import { AuthGuard } from './guard/auth.guard';
import { RolesGuard } from './guard/roles.guard'; import { RolesGuard } from './guard/roles.guard';
@ -24,7 +24,6 @@ export class AuthController {
) {} ) {}
@Post('/register') @Post('/register')
@Header('Content-Type', 'application/json')
@HttpCode(201) @HttpCode(201)
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@Roles(UserRole.Admin) @Roles(UserRole.Admin)
@ -46,9 +45,24 @@ export class AuthController {
httpOnly: true, httpOnly: true,
secure: this.configService.get<string>('NODE_ENV') !== 'development', secure: this.configService.get<string>('NODE_ENV') !== 'development',
sameSite: 'strict', sameSite: 'strict',
maxAge: 3600000, maxAge: parseInt(
this.configService.get<string>('COOKIE_MAX_AGE') || '7200000',
10,
),
}); });
return { user, csrfToken }; return { user, csrfToken };
} }
@Post('logout')
@HttpCode(200)
logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie('access_token', {
httpOnly: true,
secure: this.configService.get<string>('NODE_ENV') !== 'development',
sameSite: 'strict',
});
return { message: 'Logout berhasil' };
}
} }

View File

@ -3,7 +3,8 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import type { StringValue } from 'ms';
@Module({ @Module({
exports: [AuthService], exports: [AuthService],
@ -14,9 +15,13 @@ import { JwtModule } from '@nestjs/jwt';
global: true, global: true,
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService): JwtModuleOptions => ({
secret: configService.get<string>('JWT_SECRET'), secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '120m' }, signOptions: {
expiresIn:
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ??
'120m',
},
}), }),
}), }),
], ],

View File

@ -1,12 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service'; 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', () => { describe('AuthService', () => {
let service: 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 () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [AuthService], providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile(); }).compile();
service = module.get<AuthService>(AuthService); service = module.get<AuthService>(AuthService);
@ -15,4 +50,250 @@ describe('AuthService', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); 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<number>('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<string>('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',
);
});
});
}); });

View File

@ -2,9 +2,10 @@ import { PrismaService } from '@api/modules/prisma/prisma.service';
import { import {
ConflictException, ConflictException,
Injectable, Injectable,
InternalServerErrorException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthDtoResponse, UserRole } from './dto/auth.dto'; import { UserRole } from './dto/auth.dto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto'; import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
@ -20,8 +21,28 @@ export class AuthService {
private configService: ConfigService, private configService: ConfigService,
) {} ) {}
async isUserExisting(username: string): Promise<boolean> {
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<CreateUserDtoResponse> { async registerUser(data: CreateUserDto): Promise<CreateUserDtoResponse> {
const salt = this.configService.get<number>('BCRYPT_SALT') ?? 10; const saltEnv = this.configService.get<string>('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); const hashedPassword = await bcrypt.hash(data.password, salt);
try { try {
@ -41,12 +62,8 @@ export class AuthService {
role: userCreated.role as UserRole, role: userCreated.role as UserRole,
}; };
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { console.error('Error registering user:', error);
if (error.code === 'P2002') { throw new InternalServerErrorException();
throw new ConflictException('Username ini sudah terdaftar');
}
}
throw error;
} }
} }

View File

@ -17,19 +17,3 @@ export class AuthDto {
@Length(6, undefined, { message: 'Password minimal 6 karakter' }) @Length(6, undefined, { message: 'Password minimal 6 karakter' })
password: string; 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;
}

View File

@ -1,9 +1,157 @@
import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
describe('AuthGuard', () => { 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>(AuthGuard);
});
const createMockExecutionContext = (
cookies?: any,
headers?: any,
): ExecutionContext => {
const mockRequest = {
cookies: cookies,
headers: headers || {},
};
return {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
};
it('should be defined', () => { 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,
);
});
}); });
}); });

View File

@ -17,14 +17,23 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromCookie(request); const jwtToken = this.extractTokenFromCookie(request);
if (!token) { const csrfToken = this.extractTokenFromHeader(request);
if (!jwtToken || !csrfToken) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(jwtToken, {
secret: this.configService.get<string>('JWT_SECRET'), secret: this.configService.get<string>('JWT_SECRET'),
}); });
console.log(payload);
if (payload.csrf !== csrfToken) {
throw new UnauthorizedException(['Invalid CSRF token']);
}
request['user'] = payload; request['user'] = payload;
} catch { } catch {
throw new UnauthorizedException(); throw new UnauthorizedException();
@ -33,8 +42,8 @@ export class AuthGuard implements CanActivate {
} }
private extractTokenFromHeader(request: any): string | undefined { private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers?.authorization?.split(' ') ?? []; const token = request.headers['x-csrf-token'];
return type === 'Bearer' ? token : undefined; return token;
} }
private extractTokenFromCookie(request: Request): string | undefined { private extractTokenFromCookie(request: Request): string | undefined {

View File

@ -1,8 +1,155 @@
import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing';
import { RolesGuard } from './roles.guard'; import { RolesGuard } from './roles.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { UserRole } from '../dto/auth.dto';
describe('RolesGuard', () => { 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>(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', () => { 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)',
);
});
}); });
}); });

View File

@ -18,14 +18,14 @@ export class RolesGuard implements CanActivate {
[context.getHandler(), context.getClass()], [context.getHandler(), context.getClass()],
); );
if (!requiredRoles) { if (!requiredRoles || requiredRoles.length === 0) {
return true; return true;
} }
const { user } = context.switchToHttp().getRequest(); const { user } = context.switchToHttp().getRequest();
if (!user?.role) { 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); const hasRole = requiredRoles.some((role) => user.role === role);
@ -34,6 +34,6 @@ export class RolesGuard implements CanActivate {
return true; return true;
} }
throw new ForbiddenException('You do not have the required role'); throw new ForbiddenException(['You do not have the required role']);
} }
} }

View File

@ -1,11 +1,147 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WebsocketGuard } from './websocket.guard';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { WebsocketGuard } from './websocket.guard'; import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Socket } from 'socket.io';
describe('WebsocketGuard', () => { describe('WebsocketGuard', () => {
let guard: WebsocketGuard;
let jwtService: jest.Mocked<JwtService>;
let configService: jest.Mocked<ConfigService>;
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>(WebsocketGuard);
jwtService = module.get(JwtService);
configService = module.get(ConfigService);
});
const createMockSocket = (cookieHeader?: string): Partial<Socket> => ({
handshake: {
headers: {
cookie: cookieHeader,
},
} as any,
data: {},
disconnect: jest.fn(),
});
const createMockExecutionContext = (
socket: Partial<Socket>,
): ExecutionContext => {
return {
switchToWs: () => ({
getClient: () => socket,
}),
} as unknown as ExecutionContext;
};
it('should be defined', () => { it('should be defined', () => {
expect( expect(guard).toBeDefined();
new WebsocketGuard(new JwtService(), new ConfigService()), });
).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();
});
}); });
}); });

View File

@ -10,7 +10,7 @@ import { Socket } from 'socket.io';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
interface AuthPayload { interface AuthPayload {
sub: number; sub: bigint;
username: string; username: string;
role: string; role: string;
} }

View File

@ -2,16 +2,24 @@
import { ref } from "vue"; import { ref } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import DialogConfirm from "../DialogConfirm.vue"; import DialogConfirm from "../DialogConfirm.vue";
import { useApi } from "../../composables/useApi";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const { post } = useApi();
const logoutDialog = ref<InstanceType<typeof DialogConfirm> | null>(null); const logoutDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
const showLogoutDialog = () => { const showLogoutDialog = () => {
logoutDialog.value?.show(); logoutDialog.value?.show();
}; };
const handleLogoutConfirm = () => { const handleLogoutConfirm = async () => {
try {
await post("/auth/logout", {});
} catch (error) {
console.error("Logout error:", error);
}
localStorage.removeItem("csrf_token"); localStorage.removeItem("csrf_token");
router.push({ name: "login" }); router.push({ name: "login" });
}; };