upgrade repositories

This commit is contained in:
sbriat 2023-07-13 08:58:58 +02:00
parent f111563137
commit 0ccaafc5e0
11 changed files with 532 additions and 22 deletions

View File

@ -5,6 +5,10 @@ SERVICE_PORT=5002
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@localhost:5432/mobicoop-test?schema=auth"
# MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop
# OPA
OPA_IMAGE=openpolicyagent/opa:0.54.0
OPA_URL=http://v3-auth-opa:8181/v1/data/

View File

@ -20,7 +20,7 @@
"test": "npm run migrate:test && dotenv -e .env.test jest",
"test:unit": "jest --testPathPattern 'tests/unit/' --verbose",
"test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage",
"test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose",
"test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose --runInBand",
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/'",
"test:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",

View File

@ -1,4 +1,4 @@
import { Inject } from '@nestjs/common';
import { Inject, UnauthorizedException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DeleteUsernameCommand } from './delete-username.command';
import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-tokens';
@ -13,11 +13,20 @@ export class DeleteUsernameService implements ICommandHandler {
) {}
async execute(command: DeleteUsernameCommand): Promise<boolean> {
const username: UsernameEntity = await this.usernameRepository.findOneById(
const username: UsernameEntity = await this.usernameRepository.findByName(
command.name,
);
const usernamesCount: number = await this.usernameRepository.countUsernames(
username.getProps().userId,
);
if (usernamesCount <= 1)
throw new UnauthorizedException(
'Authentication must have at least one username',
);
username.delete();
const isDeleted: boolean = await this.usernameRepository.delete(username);
const isDeleted: boolean = await this.usernameRepository.deleteUsername(
username,
);
return isDeleted;
}
}

View File

@ -6,4 +6,6 @@ export type UsernameRepositoryPort = RepositoryPort<UsernameEntity> & {
findByType(userId: string, type: Type): Promise<UsernameEntity>;
findByName(name: string): Promise<UsernameEntity>;
updateUsername(oldName: string, entity: UsernameEntity): Promise<void>;
deleteUsername(entity: UsernameEntity): Promise<boolean>;
countUsernames(userId: string): Promise<number>;
};

View File

@ -71,11 +71,13 @@ export class UsernameRepository
updateUsername = async (
oldName: string,
entity: UsernameEntity,
): Promise<void> =>
this.updateWhere(
{
username: oldName,
},
entity,
);
): Promise<void> => this.update(oldName, entity, 'username');
deleteUsername = async (entity: UsernameEntity): Promise<boolean> =>
this.delete(entity, 'username');
countUsernames = async (userId: string): Promise<number> =>
this.count({
authUuid: userId,
});
}

View File

@ -4,7 +4,7 @@ import {
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { Controller, UnauthorizedException, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DeleteUsernameRequestDto } from './dtos/delete-username.request.dto';
@ -25,12 +25,16 @@ export class DeleteUsernameGrpcController {
try {
await this.commandBus.execute(new DeleteUsernameCommand(data));
} catch (error: any) {
if (error instanceof UnauthorizedException)
throw new RpcException({
code: RpcExceptionCode.PERMISSION_DENIED,
message: error.message,
});
if (error instanceof NotFoundException)
throw new RpcException({
code: RpcExceptionCode.NOT_FOUND,
message: error.message,
});
if (error instanceof DatabaseErrorException)
throw new RpcException({
code: RpcExceptionCode.INTERNAL,

View File

@ -0,0 +1,231 @@
import { TestingModule, Test } from '@nestjs/testing';
import { v4 } from 'uuid';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '@modules/authentication/infrastructure/prisma.service';
import { AuthenticationRepository } from '@modules/authentication/infrastructure/authentication.repository';
import { AuthenticationMapper } from '@modules/authentication/authentication.mapper';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import {
DatabaseErrorException,
NotFoundException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
import { Type } from '@modules/authentication/core/domain/username.types';
import { CreateAuthenticationProps } from '@modules/authentication/core/domain/authentication.types';
const uuid = '165192d4-398a-4469-a16b-98c02cc6f531';
const createAuthenticationProps: CreateAuthenticationProps = {
userId: uuid,
password: 'somePassword',
usernames: [
{
type: Type.EMAIL,
name: 'john.doe@email.com',
},
],
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
describe('AuthenticationRepository', () => {
let prismaService: PrismaService;
let authenticationRepository: AuthenticationRepository;
// const createAuthentications = async (nbToCreate = 10) => {
// for (let i = 0; i < nbToCreate; i++) {
// await prismaService.auth.create({
// data: {
// uuid: v4(),
// password: bcrypt.hashSync(`password-${i}`, 10),
// },
// });
// }
// };
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot()],
providers: [
AuthenticationRepository,
PrismaService,
AuthenticationMapper,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
})
// disable logging
.setLogger(mockLogger)
.compile();
prismaService = module.get<PrismaService>(PrismaService);
authenticationRepository = module.get<AuthenticationRepository>(
AuthenticationRepository,
);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.auth.deleteMany();
});
// describe('findAll', () => {
// it('should return an empty data array', async () => {
// const res = await authenticationRepository.findAll();
// expect(res).toEqual({
// data: [],
// total: 0,
// });
// });
// it('should return a data array with 8 auths', async () => {
// await createAuthentications(8);
// const auths = await authenticationRepository.findAll();
// expect(auths.data.length).toBe(8);
// expect(auths.total).toBe(8);
// });
// it('should return a data array limited to 10 authentications', async () => {
// await createAuthentications(20);
// const auths = await authenticationRepository.findAll();
// expect(auths.data.length).toBe(10);
// expect(auths.total).toBe(20);
// });
// });
describe('findOneById', () => {
it('should return an authentication', async () => {
const authToFind = await prismaService.auth.create({
data: {
uuid: v4(),
password: bcrypt.hashSync(`password`, 10),
},
});
const auth = await authenticationRepository.findOneById(authToFind.uuid);
expect(auth.id).toBe(authToFind.uuid);
});
it('should throw an exception if record is not found', async () => {
await expect(
authenticationRepository.findOneById(
'544572be-11fb-4244-8235-587221fc9104',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('create', () => {
it('should create an authentication', async () => {
const beforeCount = await prismaService.auth.count();
const authenticationToCreate: AuthenticationEntity =
await AuthenticationEntity.create(createAuthenticationProps);
await authenticationRepository.insert(authenticationToCreate);
const afterCount = await prismaService.auth.count();
expect(afterCount - beforeCount).toBe(1);
});
it('should throw a UniqueConstraintException if authentication already exists', async () => {
await prismaService.auth.create({
data: {
uuid: uuid,
password: bcrypt.hashSync(`password`, 10),
},
});
const authenticationToCreate: AuthenticationEntity =
await AuthenticationEntity.create(createAuthenticationProps);
await expect(
authenticationRepository.insert(authenticationToCreate),
).rejects.toBeInstanceOf(UniqueConstraintException);
});
});
describe('update', () => {
it('should update an authentication', async () => {
const authenticationToUpdate = await prismaService.auth.create({
data: {
uuid: v4(),
password: bcrypt.hashSync(`password`, 10),
},
});
const toUpdate: AuthenticationEntity = await AuthenticationEntity.create(
createAuthenticationProps,
);
await authenticationRepository.update(
authenticationToUpdate.uuid,
toUpdate,
);
const updatedAuthentication = await prismaService.auth.findUnique({
where: {
uuid: toUpdate.id,
},
});
expect(updatedAuthentication.uuid).toBe(uuid);
expect(authenticationToUpdate.updatedAt.getTime()).toBeLessThan(
updatedAuthentication.updatedAt.getTime(),
);
});
it('should throw a DatabaseException if id is unknown', async () => {
const toUpdate: AuthenticationEntity = await AuthenticationEntity.create(
createAuthenticationProps,
);
await expect(
authenticationRepository.update(
'544572be-11fb-4244-8235-587221fc9104',
toUpdate,
),
).rejects.toBeInstanceOf(DatabaseErrorException);
});
});
describe('delete', () => {
it('should delete an authentication', async () => {
await prismaService.auth.create({
data: {
uuid,
password: bcrypt.hashSync(`password`, 10),
},
});
const toDelete: AuthenticationEntity = await AuthenticationEntity.create(
createAuthenticationProps,
);
await authenticationRepository.delete(toDelete);
const count = await prismaService.auth.count();
expect(count).toBe(0);
});
it('should throw a DatabaseException if authentication does not exist', async () => {
const toDelete: AuthenticationEntity = await AuthenticationEntity.create(
createAuthenticationProps,
);
await expect(
authenticationRepository.delete(toDelete),
).rejects.toBeInstanceOf(DatabaseErrorException);
});
});
});

View File

@ -0,0 +1,202 @@
import { TestingModule, Test } from '@nestjs/testing';
import { PrismaService } from '@modules/authentication/infrastructure/prisma.service';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import {
DatabaseErrorException,
NotFoundException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import {
CreateUsernameProps,
Type,
} from '@modules/authentication/core/domain/username.types';
import { UsernameRepository } from '@modules/authentication/infrastructure/username.repository';
import { UsernameMapper } from '@modules/authentication/username.mapper';
import * as bcrypt from 'bcrypt';
import { UsernameEntity } from '@modules/authentication/core/domain/username.entity';
const authUuid = 'a4524d22-7be3-46cd-8444-3145470476dc';
const createUsernameProps: CreateUsernameProps = {
userId: authUuid,
type: Type.EMAIL,
name: 'john.doe@email.com',
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
describe('UsernameRepository', () => {
let prismaService: PrismaService;
let usernameRepository: UsernameRepository;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot()],
providers: [
UsernameRepository,
PrismaService,
UsernameMapper,
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
})
// disable logging
.setLogger(mockLogger)
.compile();
prismaService = module.get<PrismaService>(PrismaService);
usernameRepository = module.get<UsernameRepository>(UsernameRepository);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.username.deleteMany();
await prismaService.auth.deleteMany();
await prismaService.auth.create({
data: {
uuid: authUuid,
password: bcrypt.hashSync(`password`, 10),
},
});
});
describe('findOne', () => {
it('should return a Username', async () => {
await prismaService.username.create({
data: {
authUuid,
username: 'john.doe@email.com',
type: Type.EMAIL,
},
});
const username = await usernameRepository.findOne({
username: 'john.doe@email.com',
});
expect(username.id).toBe('john.doe@email.com');
});
it('should throw an exception if record is not found', async () => {
await expect(
usernameRepository.findOne({
username: 'jane.doe@email.com',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('create', () => {
it('should create a username', async () => {
const beforeCount = await prismaService.username.count();
const usernameToCreate: UsernameEntity = await UsernameEntity.create(
createUsernameProps,
);
await usernameRepository.insert(usernameToCreate);
const afterCount = await prismaService.username.count();
expect(afterCount - beforeCount).toBe(1);
});
it('should throw a UniqueConstraintException if username already exists', async () => {
await prismaService.username.create({
data: {
authUuid,
type: Type.EMAIL,
username: 'john.doe@email.com',
},
});
const usernameToCreate: UsernameEntity = await UsernameEntity.create(
createUsernameProps,
);
await expect(
usernameRepository.insert(usernameToCreate),
).rejects.toBeInstanceOf(UniqueConstraintException);
});
});
describe('update username', () => {
it('should update the name of a username', async () => {
const usernameToUpdate = await prismaService.username.create({
data: {
authUuid,
type: Type.EMAIL,
username: 'johnny.doe@email.com',
},
});
const toUpdate: UsernameEntity = await UsernameEntity.create(
createUsernameProps,
);
await usernameRepository.updateUsername(
usernameToUpdate.username,
toUpdate,
);
const updatedUsername = await prismaService.username.findUnique({
where: {
username: toUpdate.id,
},
});
expect(updatedUsername.username).toBe('john.doe@email.com');
expect(usernameToUpdate.updatedAt.getTime()).toBeLessThan(
updatedUsername.updatedAt.getTime(),
);
});
it('should throw a DatabaseException if id is unknown', async () => {
const toUpdate: UsernameEntity = await UsernameEntity.create(
createUsernameProps,
);
await expect(
usernameRepository.updateUsername('jane.doe@email.com', toUpdate),
).rejects.toBeInstanceOf(DatabaseErrorException);
});
});
describe('delete', () => {
it('should delete a username', async () => {
await prismaService.username.create({
data: {
authUuid,
type: Type.EMAIL,
username: 'john.doe@email.com',
},
});
const toDelete: UsernameEntity = await UsernameEntity.create(
createUsernameProps,
);
await usernameRepository.delete(toDelete);
const count = await prismaService.username.count();
expect(count).toBe(0);
});
it('should throw a DatabaseException if username does not exist', async () => {
const toDelete: UsernameEntity = await UsernameEntity.create(
createUsernameProps,
);
await expect(usernameRepository.delete(toDelete)).rejects.toBeInstanceOf(
DatabaseErrorException,
);
});
});
});

View File

@ -2,6 +2,7 @@ import { USERNAME_REPOSITORY } from '@modules/authentication/authentication.di-t
import { DeleteUsernameCommand } from '@modules/authentication/core/application/commands/delete-username/delete-username.command';
import { DeleteUsernameService } from '@modules/authentication/core/application/commands/delete-username/delete-username.service';
import { DeleteUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/delete-username.request.dto';
import { UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
const deleteUsernameRequest: DeleteUsernameRequestDto = {
@ -10,11 +11,18 @@ const deleteUsernameRequest: DeleteUsernameRequestDto = {
const mockUsernameEntity = {
delete: jest.fn(),
getProps: jest.fn().mockImplementation(() => ({
userId: 'b1643072-757f-4e3b-87f2-63af3684afb8',
})),
};
const mockUsernameRepository = {
findOneById: jest.fn().mockImplementation(() => mockUsernameEntity),
delete: jest.fn().mockImplementationOnce(() => true),
findByName: jest.fn().mockImplementation(() => mockUsernameEntity),
countUsernames: jest
.fn()
.mockImplementationOnce(() => 2)
.mockImplementationOnce(() => 1),
deleteUsername: jest.fn().mockImplementationOnce(() => true),
};
describe('Delete Username Service', () => {
@ -50,5 +58,10 @@ describe('Delete Username Service', () => {
);
expect(result).toBeTruthy();
});
it('should throw an exception when trying to delete the last username', async () => {
await expect(
deleteUsernameService.execute(deleteUsernameCommand),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
});

View File

@ -22,8 +22,9 @@ const mockPrismaService = {
};
return record;
}),
update: jest.fn().mockImplementation(),
delete: jest.fn().mockImplementation(),
count: jest.fn().mockImplementation(),
},
};
@ -67,6 +68,7 @@ describe('Username repository', () => {
it('should be defined', () => {
expect(usernameRepository).toBeDefined();
});
it('should find a username by its userId and Type', async () => {
jest.spyOn(usernameRepository, 'findOne');
const username: UsernameEntity = await usernameRepository.findByType(
@ -80,6 +82,7 @@ describe('Username repository', () => {
});
expect(username.getProps().name).toBe('john.doe@email.com');
});
it('should find a username by its name', async () => {
jest.spyOn(usernameRepository, 'findOne');
const username: UsernameEntity = await usernameRepository.findByName(
@ -91,8 +94,9 @@ describe('Username repository', () => {
});
expect(username.getProps().name).toBe('john.doe@email.com');
});
it('should update a username', async () => {
jest.spyOn(usernameRepository, 'updateWhere');
jest.spyOn(usernameRepository, 'update');
const usernameToUpdate: UsernameEntity = await UsernameEntity.create({
userId: '165192d4-398a-4469-a16b-98c02cc6f531',
type: Type.EMAIL,
@ -102,12 +106,35 @@ describe('Username repository', () => {
'john.doe@email.com',
usernameToUpdate,
);
expect(usernameRepository.updateWhere).toHaveBeenCalledTimes(1);
expect(usernameRepository.updateWhere).toHaveBeenCalledWith(
{
username: 'john.doe@email.com',
},
expect(usernameRepository.update).toHaveBeenCalledTimes(1);
expect(usernameRepository.update).toHaveBeenCalledWith(
'john.doe@email.com',
usernameToUpdate,
'username',
);
});
it('should delete a username', async () => {
jest.spyOn(usernameRepository, 'delete');
const usernameToDelete: UsernameEntity = await UsernameEntity.create({
userId: '165192d4-398a-4469-a16b-98c02cc6f531',
type: Type.EMAIL,
name: 'john.doe@new-email.com',
});
await usernameRepository.deleteUsername(usernameToDelete);
expect(usernameRepository.delete).toHaveBeenCalledTimes(1);
expect(usernameRepository.delete).toHaveBeenCalledWith(
usernameToDelete,
'username',
);
});
it('should count usernames for a given userId', async () => {
jest.spyOn(usernameRepository, 'count');
await usernameRepository.countUsernames('john.doe@email.com');
expect(usernameRepository.count).toHaveBeenCalledTimes(1);
expect(usernameRepository.count).toHaveBeenCalledWith({
authUuid: 'john.doe@email.com',
});
});
});

View File

@ -5,6 +5,7 @@ import {
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { DeleteUsernameGrpcController } from '@modules/authentication/interface/grpc-controllers/delete-username.grpc.controller';
import { DeleteUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/delete-username.request.dto';
import { UnauthorizedException } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
@ -17,6 +18,9 @@ const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new UnauthorizedException();
})
.mockImplementationOnce(() => {
throw new NotFoundException();
})
@ -61,6 +65,18 @@ describe('Delete Username Grpc Controller', () => {
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException when trying to delete the last username', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteUsernameGrpcController.delete(deleteUsernameRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.PERMISSION_DENIED);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if username does not exist', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);