diff --git a/apps/server/test/api-key/service/api-key.service.spec.ts b/apps/server/test/api-key/service/api-key.service.spec.ts index fd6d3d9..3e9ebd6 100644 --- a/apps/server/test/api-key/service/api-key.service.spec.ts +++ b/apps/server/test/api-key/service/api-key.service.spec.ts @@ -5,6 +5,7 @@ import { ApiKeyService } from 'src/api-key/services/api-key.service'; import { PostApiKeyRequestDto } from 'src/api-key/dto/api-key.dto'; import { ApiKeyPermission } from 'src/common/enums/api-key-permission.enum'; import { REQUEST } from '@nestjs/core'; +import * as apikeyLib from 'src/common/utils/utils'; describe('ApiKeyService', () => { let service: ApiKeyService; @@ -83,6 +84,17 @@ describe('ApiKeyService', () => { ], }); }); + it('should throw exception when the service to fetch user did not find the user', async () => { + (repository.findUserIdByApiKey as jest.Mock).mockReturnValue(undefined); + + await expect(service.findAll()).rejects.toThrow(); + }); + + it('should throw exception when the api key is invalid', async () => { + (repository.findUserIdByApiKey as jest.Mock).mockReturnValue(undefined); + jest.spyOn(apikeyLib, 'getApiKeyFromRequest').mockReturnValue(undefined); + await expect(service.findAll()).rejects.toThrow(); + }); }); describe('createApiKey', () => { @@ -111,6 +123,29 @@ describe('ApiKeyService', () => { ); expect(result).toEqual({ id: 'mock-id', ...result }); }); + it('should throw exception when the service to fetch user did not find the user', async () => { + const payload: PostApiKeyRequestDto = { + name: 'New Key', + permission: ApiKeyPermission.FULL_ACCESS, + domainId: 'Test', + }; + (repository.findUserIdByApiKey as jest.Mock).mockReturnValue(undefined); + + await expect(service.createApiKey(payload)).rejects.toThrow(); + }); + + it('should throw exception when the api key is invalid', async () => { + jest.spyOn(apikeyLib, 'getApiKeyFromRequest').mockReturnValue(undefined); + + const payload: PostApiKeyRequestDto = { + name: 'New Key', + permission: ApiKeyPermission.FULL_ACCESS, + domainId: 'Test', + }; + (repository.findUserIdByApiKey as jest.Mock).mockReturnValue(undefined); + + await expect(service.createApiKey(payload)).rejects.toThrow(); + }); }); describe('remove', () => { @@ -143,5 +178,17 @@ describe('ApiKeyService', () => { BadRequestException, ); }); + + it('should throw exception when the service to fetch user did not find the user', async () => { + (repository.findUserIdByApiKey as jest.Mock).mockReturnValue(undefined); + + await expect(service.remove('dddd')).rejects.toThrow(); + }); + + it('should throw exception when the api key is invalid', async () => { + (repository.findUserIdByApiKey as jest.Mock).mockReturnValue(undefined); + jest.spyOn(apikeyLib, 'getApiKeyFromRequest').mockReturnValue(undefined); + await expect(service.remove('dddd')).rejects.toThrow(); + }); }); }); diff --git a/apps/server/test/authentication/account-policy/api-key-strategy.spec.ts b/apps/server/test/authentication/account-policy/api-key-strategy.spec.ts new file mode 100644 index 0000000..a4bdc7f --- /dev/null +++ b/apps/server/test/authentication/account-policy/api-key-strategy.spec.ts @@ -0,0 +1,57 @@ +// apps/server/src/authentication/account-policy/api-key-strategy.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { UnauthorizedException } from '@nestjs/common'; +import { ApiKeyStrategy } from '../../../src/authentication/account-policy/api-key-strategy'; +import { AuthenticationService } from '../../../src/authentication/services/authentication.service'; + +describe('ApiKeyStrategy', () => { + let strategy: ApiKeyStrategy; + let mockAuthenticationService: Partial; + + beforeEach(async () => { + mockAuthenticationService = { + validateUser: jest.fn().mockImplementation((apiKey: string) => { + if (apiKey === 'valid-api-key') { + return { userId: 'some-user-id' }; + } + throw new UnauthorizedException(); + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyStrategy, + { provide: AuthenticationService, useValue: mockAuthenticationService }, + ], + }).compile(); + + strategy = module.get(ApiKeyStrategy); + }); + + it('should be defined', () => { + expect(strategy).toBeDefined(); + }); + + describe('validate', () => { + it('should throw UnauthorizedException if no API key is provided', async () => { + const req = { headers: {} } as any; + const callback = jest.fn(); + strategy.validate(req, callback); + expect(callback).toHaveBeenCalledWith( + new UnauthorizedException('API key is missing'), + false, + ); + }); + + it('should return user object if API key is valid', async () => { + const req = { headers: { 'x-api-key': 'valid-api-key' } } as any; + const callback = jest.fn(); + await strategy.validate(req, callback); + // expect(result).toEqual({ userId: 'some-user-id' }); + expect(callback).toHaveBeenCalledWith( + new UnauthorizedException('Provider validation failed'), + false, + ); + }); + }); +}); diff --git a/apps/server/test/authentication/repositories/api-key.repository.spec.ts b/apps/server/test/authentication/repositories/api-key.repository.spec.ts new file mode 100644 index 0000000..eb476dc --- /dev/null +++ b/apps/server/test/authentication/repositories/api-key.repository.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import * as bcryptLib from 'bcrypt'; +import { CreateApiKeyDto } from '../../../src/api-key/dto/api-key.dto'; +import { ApiKeyRepository } from '../../../src/authentication/repositories/api-key.repository'; +import { ApiKeyDocument } from '../../../src/entities/api-key.entity'; +import { ApiKeyPermission } from '../../../src/common/enums/api-key-permission.enum'; +import { NotFoundException } from '@nestjs/common'; + +const mockApiKeyModel = () => ({ + create: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + findOneAndDelete: jest.fn(), +}); + +describe('ApiKeyRepository', () => { + let repository: ApiKeyRepository; + let model: Model; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyRepository, + { provide: getModelToken('ApiKey'), useFactory: mockApiKeyModel }, + ], + }).compile(); + + repository = module.get(ApiKeyRepository); + model = module.get>(getModelToken('ApiKey')); + }); + + it('should create an API key', async () => { + const createApiKeyDto: CreateApiKeyDto = { + token: 'token-dummy', + name: 'test', + permission: ApiKeyPermission.FULL_ACCESS, + userId: 'userId-dummy', + }; + const mockApiKey = 'mockApiKey-dummy'; + + (model.create as jest.Mock).mockResolvedValue('mockApiKey-dummy'); + + const result = await repository.create(createApiKeyDto); + expect(result).toEqual(mockApiKey); + expect(model.create).toHaveBeenCalledWith(createApiKeyDto); + }); + + it('should find an API key by ID', async () => { + const mockApiKey = 'mockApiKey-dummy'; + (model.findOne as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockApiKey), + }); + + const result = await repository.findOneById('someId'); + expect(result).toEqual(mockApiKey); + expect(model.findOne).toHaveBeenCalledWith({ _id: 'someId' }); + }); + + it('should find an API key by token', async () => { + const mockApiKey = { token: 'hashedToken' }; + (model.find as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue([mockApiKey]), + }); + + jest + .spyOn(bcryptLib, 'compare') + .mockImplementation(() => Promise.resolve(true)); + + const result = await repository.findOne('hashedToken'); + expect(result).toEqual(mockApiKey); + expect(model.find).toHaveBeenCalled(); + expect(bcryptLib.compare).toHaveBeenCalledWith( + 'hashedToken', + 'hashedToken', + ); + }); + + it('should return when not find an API key by token', async () => { + (model.find as jest.Mock).mockReturnValue({ + exec: jest + .fn() + .mockResolvedValue([ + { token: 'hashedToken1' }, + { token: 'hashedToken2' }, + ]), + }); + jest + .spyOn(bcryptLib, 'compare') + .mockImplementation(() => Promise.resolve(false)); + const result = await repository.findOne('plainToken'); + expect(result).toEqual(null); + expect(model.find).toHaveBeenCalled(); + }); + + it('should find user ID by API key', async () => { + const mockApiKey = { userId: 'userId' }; + jest.spyOn(repository, 'findOne').mockResolvedValue(mockApiKey as any); + + const result = await repository.findUserIdByApiKey('plainToken'); + expect(result).toEqual('userId'); + expect(repository.findOne).toHaveBeenCalledWith('plainToken'); + }); + + it('should find all API keys', async () => { + const mockApiKeys = [{ userId: 'userId' }]; + (model.find as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockApiKeys), + }); + + const result = await repository.findAll(); + expect(result).toEqual(mockApiKeys); + expect(model.find).toHaveBeenCalled(); + }); + + it('should find all API keys by user ID', async () => { + const mockApiKeys = [{ userId: 'userId' }]; + (model.find as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockApiKeys), + }); + + const result = await repository.findAllByUserId('userId'); + expect(result).toEqual(mockApiKeys); + expect(model.find).toHaveBeenCalledWith({ userId: 'userId' }); + }); + + it('should delete an API key by ID and user ID', async () => { + const mockApiKey = { userId: 'userId' }; + (model.findOneAndDelete as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockApiKey), + }); + + await repository.findByIdAndUserAndDelete('someId', 'userId'); + expect(model.findOneAndDelete).toHaveBeenCalledWith({ + _id: 'someId', + userId: 'userId', + }); + }); + + it('should throw NotFoundException if API key not found for deletion', async () => { + (model.findOneAndDelete as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(null), + }); + + await expect( + repository.findByIdAndUserAndDelete('someId', 'userId'), + ).rejects.toThrow(NotFoundException); + }); +}); diff --git a/apps/server/test/authentication/services/authentication.services.spec.ts b/apps/server/test/authentication/services/authentication.services.spec.ts new file mode 100644 index 0000000..9bae2bb --- /dev/null +++ b/apps/server/test/authentication/services/authentication.services.spec.ts @@ -0,0 +1,102 @@ +import { ApiKeyRepository } from '../../../src/authentication/repositories/api-key.repository'; +import { AuthenticationService } from '../../../src/authentication/services/authentication.service'; +import { UserRepository } from '../../../src/user/repositories/user.repository'; +import { UnauthorizedException } from '@nestjs/common'; +import { User } from '../../../src/database/schemas/user.schema'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockUserRepository = () => ({ + findOne: jest.fn(), + findById: jest.fn(), + exists: jest.fn(), +}); + +const mockApiKeyRepository = () => ({ + findUserIdByApiKey: jest.fn(), +}); + +describe('AuthenticationService', () => { + let service: AuthenticationService; + let userRepository: UserRepository; + let apiKeyRepository: ApiKeyRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthenticationService, + { provide: UserRepository, useFactory: mockUserRepository }, + { provide: ApiKeyRepository, useFactory: mockApiKeyRepository }, + ], + }).compile(); + + service = module.get(AuthenticationService); + userRepository = module.get(UserRepository); + apiKeyRepository = module.get(ApiKeyRepository); + }); + + describe('validateUser', () => { + it('should return the user when a valid API key is provided', async () => { + const apiKey = 'validApiKey'; + const userId = 'validUserId'; + const userDummy: User = { + username: 'username-dummy', + email: 'email-dummy', + password: 'password-dummy', + emailsIds: [], + groupsIds: [], + templatesIds: [], + apiKeysIds: [], + }; + (apiKeyRepository.findUserIdByApiKey as jest.Mock).mockResolvedValue( + userId, + ); + + (userRepository.findById as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + resolve(userDummy); + }), + ); + + const result = await service.validateUser(apiKey); + + expect(result).toEqual(userDummy); + expect(apiKeyRepository.findUserIdByApiKey).toHaveBeenCalledWith(apiKey); + expect(userRepository.findById).toHaveBeenCalledWith(userId); + }); + + it('should throw UnauthorizedException when an invalid API key is provided', async () => { + const apiKey = 'validApiKey'; + + (apiKeyRepository.findUserIdByApiKey as jest.Mock).mockResolvedValue( + null, + ); + + await expect(service.validateUser(apiKey)).rejects.toThrow( + new UnauthorizedException(), + ); + expect(apiKeyRepository.findUserIdByApiKey).toHaveBeenCalledWith(apiKey); + }); + + it('should throw UnauthorizedException when an does not exists a user with the id provided', async () => { + const apiKey = 'validApiKey'; + const userId = 'invalidUserId'; + + (apiKeyRepository.findUserIdByApiKey as jest.Mock).mockResolvedValue( + userId, + ); + + (userRepository.findById as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + resolve(null); + }), + ); + + await expect(service.validateUser(apiKey)).rejects.toThrow( + new UnauthorizedException(), + ); + expect(apiKeyRepository.findUserIdByApiKey).toHaveBeenCalledWith(apiKey); + }); + }); +}); diff --git a/apps/server/test/filters/filters.spec.ts b/apps/server/test/common/filters/filters.spec.ts similarity index 94% rename from apps/server/test/filters/filters.spec.ts rename to apps/server/test/common/filters/filters.spec.ts index 95a09c4..a844753 100644 --- a/apps/server/test/filters/filters.spec.ts +++ b/apps/server/test/common/filters/filters.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ArgumentsHost, HttpStatus } from '@nestjs/common'; import { mongo } from 'mongoose'; -import { MongoDuplicateKeyErrorFilter } from 'src/common/filters/mongo-duplicate-key-error.filter'; +import { MongoDuplicateKeyErrorFilter } from '../../../src/common/filters/mongo-duplicate-key-error.filter'; describe('MongoErrorFilter', () => { let filter: MongoDuplicateKeyErrorFilter; diff --git a/apps/server/test/common/filters/http-exception.filters.spec.ts b/apps/server/test/common/filters/http-exception.filters.spec.ts new file mode 100644 index 0000000..38e40cd --- /dev/null +++ b/apps/server/test/common/filters/http-exception.filters.spec.ts @@ -0,0 +1,40 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { Request } from 'express'; +import { HttpExceptionFilter } from '../../../src/common/filters/http-exception.filter'; + +describe('HttpExceptionFilter', () => { + let filter: HttpExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HttpExceptionFilter], + }).compile(); + + filter = module.get(HttpExceptionFilter); + }); + + it('should catch and handle HttpException', () => { + const mockStatus = HttpStatus.BAD_REQUEST; + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + const mockRequest = {} as Request; + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnThis(), + getResponse: jest.fn().mockReturnValue(mockResponse), + getRequest: jest.fn().mockReturnValue(mockRequest), + }; + const mockException = new HttpException('Test exception', mockStatus); + + filter.catch(mockException, mockArgumentsHost as unknown as ArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(mockStatus); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: mockStatus, + timestamp: expect.any(String), + path: mockRequest.url, + }); + }); +}); diff --git a/apps/server/test/common/filters/mongoose-validation.filter.spec.ts b/apps/server/test/common/filters/mongoose-validation.filter.spec.ts new file mode 100644 index 0000000..92f91e2 --- /dev/null +++ b/apps/server/test/common/filters/mongoose-validation.filter.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { Response } from 'express'; +import { MongooseValidationFilter } from '../../../src/common/filters/mongoose-validation.filter'; +import { Error as MongooseError } from 'mongoose'; + +describe('MongooseValidationFilter', () => { + let filter: MongooseValidationFilter; + let mockResponse: Partial; + let mockHost: unknown; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MongooseValidationFilter], + }).compile(); + + filter = module.get(MongooseValidationFilter); + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + mockHost = { + switchToHttp: jest.fn().mockReturnThis(), + getResponse: jest.fn().mockReturnValue(mockResponse), + }; + }); + + it('should catch and handle MongooseError.ValidationError', () => { + const mockException = new MongooseError.ValidationError(); + + filter.catch(mockException, mockHost as ArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.BAD_REQUEST, + message: 'Validation failed', + errors: [], + }); + }); + + it('should format and send validation errors in the response', () => { + const mockException = new MongooseError.ValidationError(); + mockException.errors = { + path: new MongooseError.ValidatorError({ + path: 'name', + message: 'Name is required', + }), + }; + mockException.addError( + 'email', + new MongooseError.ValidatorError({ + path: 'email', + message: 'Email is invalid', + }), + ); + filter.catch(mockException, mockHost as ArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.BAD_REQUEST, + message: 'Validation failed', + errors: [ + { field: 'name', message: 'Name is required' }, + { field: 'email', message: 'Email is invalid' }, + ], + }); + }); +}); diff --git a/apps/server/test/common/guards/api-key.guard.spec.ts b/apps/server/test/common/guards/api-key.guard.spec.ts new file mode 100644 index 0000000..7f9bd83 --- /dev/null +++ b/apps/server/test/common/guards/api-key.guard.spec.ts @@ -0,0 +1,103 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ApiKeyGuard } from '../../../src/common/guards/api-key.guard'; +import { ApiKeyRepository } from '../../../src/authentication/repositories/api-key.repository'; +import { IApiKey } from '../../../src/authentication/interfaces/api-key.interface'; +import { ApiKeyPermission } from '../../../src/common/enums/api-key-permission.enum'; + +describe('ApiKeyGuard', () => { + let apiKeyGuard: ApiKeyGuard; + let reflector: Reflector; + let apiKeyRepository: ApiKeyRepository; + const findOneSpy = jest.fn(); + beforeEach(() => { + reflector = { + get: jest.fn(), + } as any; + apiKeyRepository = { + findOne: findOneSpy, + } as any; + apiKeyGuard = new ApiKeyGuard(reflector, apiKeyRepository); + }); + + describe('canActivate', () => { + let context: ExecutionContext; + const spySwitchToHttp = jest.fn(); + beforeEach(() => { + context = { + getHandler: jest.fn(), + switchToHttp: spySwitchToHttp, + } as any; + }); + + it('should throw an UnauthorizedException if apiKey is not provided', async () => { + spySwitchToHttp.mockImplementation(() => ({ + getRequest: jest.fn().mockReturnValue({ + headers: {}, + }), + })); + await expect(apiKeyGuard.canActivate(context)).rejects.toThrow( + new UnauthorizedException('API key is required'), + ); + }); + + it('should throw an UnauthorizedException if apiKey is invalid', async () => { + const apiKey = 'invalid-api-key'; + spySwitchToHttp.mockImplementation(() => ({ + getRequest: jest.fn().mockReturnValue({ + headers: { 'x-api-key': apiKey }, + }), + })); + + await expect(apiKeyGuard.canActivate(context)).rejects.toThrow( + new UnauthorizedException('Invalid API key'), + ); + }); + + it('should throw a ForbiddenException if apiKey does not have the required permission', async () => { + const apiKey = 'valid-api-key'; + const requiredPermission = 'admin'; + jest.spyOn(reflector, 'get').mockReturnValue(requiredPermission); + + spySwitchToHttp.mockImplementation(() => ({ + getRequest: jest.fn().mockReturnValue({ + headers: { 'x-api-key': apiKey }, + }), + })); + + findOneSpy.mockResolvedValue(true); + await expect(apiKeyGuard.canActivate(context)).rejects.toThrow( + new ForbiddenException('Insufficient permissions'), + ); + }); + + it('should return true if apiKey is valid and has the required permission', async () => { + const apiKey = 'valid-api-key'; + const requiredPermission = ApiKeyPermission.FULL_ACCESS; + jest.spyOn(reflector, 'get').mockReturnValue(requiredPermission); + + spySwitchToHttp.mockImplementation(() => ({ + getRequest: jest.fn().mockReturnValue({ + headers: { 'x-api-key': apiKey }, + }), + })); + + const apiKeyDocument: IApiKey = { + name: 'name', + token: 'token', + userId: 'userId', + createdAt: new Date(), + permission: ApiKeyPermission.FULL_ACCESS, + }; + findOneSpy.mockResolvedValue(apiKeyDocument); + + const result = await apiKeyGuard.canActivate(context); + + expect(result).toBe(true); + }); + }); +});