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:
parent
74d5da7475
commit
7b0873e0da
|
|
@ -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<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 () => {
|
||||
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>(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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>('NODE_ENV') !== 'development',
|
||||
sameSite: 'strict',
|
||||
maxAge: 3600000,
|
||||
maxAge: parseInt(
|
||||
this.configService.get<string>('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<string>('NODE_ENV') !== 'development',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
return { message: 'Logout berhasil' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '120m' },
|
||||
signOptions: {
|
||||
expiresIn:
|
||||
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ??
|
||||
'120m',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>(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<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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,14 +17,23 @@ export class AuthGuard implements CanActivate {
|
|||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
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<string>('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 {
|
||||
|
|
|
|||
|
|
@ -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>(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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Socket } from 'socket.io';
|
|||
import * as cookie from 'cookie';
|
||||
|
||||
interface AuthPayload {
|
||||
sub: number;
|
||||
sub: bigint;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<InstanceType<typeof DialogConfirm> | 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" });
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user