new authorization

This commit is contained in:
sbriat 2023-07-06 16:23:18 +02:00
parent bbcd2cdb9e
commit 470a93879e
97 changed files with 847 additions and 172 deletions

View File

@ -91,12 +91,12 @@
"ts"
],
"modulePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"rootDir": "src",
@ -108,15 +108,19 @@
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".di-tokens.ts",
".response.ts",
".port.ts",
"prisma.service.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
},
"testEnvironment": "node"
}
}

View File

@ -4,7 +4,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
// import { AuthorizationModule } from './modules/authorization/authorization.module';
// import { HealthModule } from './modules/health/health.module';
import { AuthenticationModule } from '@modules/newauthentication/authentication.module';
import { AuthenticationModule } from '@modules/authentication/authentication.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import {
MessageBrokerModule,

View File

@ -15,7 +15,7 @@ async function bootstrap() {
protoPath: [
join(
__dirname,
'modules/newauthentication/interface/grpc-controllers/authentication.proto',
'modules/authentication/interface/grpc-controllers/authentication.proto',
),
join(
__dirname,

View File

@ -1 +1,2 @@
export const AUTHENTICATION_REPOSITORY = Symbol('AUTHENTICATION_REPOSITORY');
export const USERNAME_REPOSITORY = Symbol('USERNAME_REPOSITORY');

View File

@ -52,6 +52,7 @@ export class AuthenticationMapper
userId: record.uuid,
password: record.password,
usernames: record.usernames.map((username: UsernameModel) => ({
userId: record.uuid,
name: username.username,
type: Type[username.type],
})),

View File

@ -1,66 +1,72 @@
import { Module } from '@nestjs/common';
import { Module, Provider } from '@nestjs/common';
import { CreateAuthenticationGrpcController } from './interface/grpc-controllers/create-authentication.grpc.controller';
import { CreateAuthenticationService } from './core/application/commands/create-authentication/create-authentication.service';
import { AuthenticationMapper } from './authentication.mapper';
import {
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
} from './authentication.di-tokens';
import { AuthenticationRepository } from './infrastructure/authentication.repository';
import { PrismaService } from './infrastructure/prisma.service';
import { CqrsModule } from '@nestjs/cqrs';
import { DatabaseModule } from '../database/database.module';
import { AuthenticationController } from './adapters/primaries/authentication.controller';
import { CreateAuthenticationUseCase } from './domain/usecases/create-authentication.usecase';
import { ValidateAuthenticationUseCase } from './domain/usecases/validate-authentication.usecase';
import { AuthenticationProfile } from './mappers/authentication.profile';
import { AuthenticationRepository } from './adapters/secondaries/authentication.repository';
import { UpdateUsernameUseCase } from './domain/usecases/update-username.usecase';
import { UsernameProfile } from './mappers/username.profile';
import { AddUsernameUseCase } from './domain/usecases/add-username.usecase';
import { UpdatePasswordUseCase } from './domain/usecases/update-password.usecase';
import { DeleteUsernameUseCase } from './domain/usecases/delete-username.usecase';
import { DeleteAuthenticationUseCase } from './domain/usecases/delete-authentication.usecase';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthenticationMessagerController } from './adapters/primaries/authentication-messager.controller';
import { Messager } from './adapters/secondaries/messager';
import { DeleteAuthenticationGrpcController } from './interface/grpc-controllers/delete-authentication.grpc.controller';
import { DeleteAuthenticationService } from './core/application/commands/delete-authentication/delete-authentication.service';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { UsernameRepository } from './infrastructure/username.repository';
import { UsernameMapper } from './username.mapper';
import { AddUsernameGrpcController } from './interface/grpc-controllers/add-username.grpc.controller';
import { AddUsernameService } from './core/application/commands/add-usernames/add-username.service';
const grpcControllers = [
CreateAuthenticationGrpcController,
DeleteAuthenticationGrpcController,
AddUsernameGrpcController,
];
const commandHandlers: Provider[] = [
CreateAuthenticationService,
DeleteAuthenticationService,
AddUsernameService,
];
const mappers: Provider[] = [AuthenticationMapper, UsernameMapper];
const repositories: Provider[] = [
{
provide: AUTHENTICATION_REPOSITORY,
useClass: AuthenticationRepository,
},
{
provide: USERNAME_REPOSITORY,
useClass: UsernameRepository,
},
];
const messageBrokers: Provider[] = [
{
provide: MESSAGE_PUBLISHER,
useClass: MessageBrokerPublisher,
},
];
const orms: Provider[] = [PrismaService];
@Module({
imports: [
DatabaseModule,
CqrsModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
handlers: {
userUpdate: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'user.update',
},
userDelete: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'user.delete',
},
},
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
enableControllerDiscovery: true,
}),
inject: [ConfigService],
}),
],
controllers: [AuthenticationController, AuthenticationMessagerController],
imports: [CqrsModule],
controllers: [...grpcControllers],
providers: [
AuthenticationProfile,
UsernameProfile,
AuthenticationRepository,
Messager,
ValidateAuthenticationUseCase,
CreateAuthenticationUseCase,
AddUsernameUseCase,
UpdateUsernameUseCase,
UpdatePasswordUseCase,
DeleteUsernameUseCase,
DeleteAuthenticationUseCase,
...commandHandlers,
...mappers,
...repositories,
...messageBrokers,
...orms,
],
exports: [
PrismaService,
AuthenticationMapper,
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
],
exports: [],
})
export class AuthenticationModule {}

View File

@ -0,0 +1,13 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { Username } from '../../types/username';
export class AddUsernameCommand extends Command {
readonly userId: string;
readonly username: Username;
constructor(props: CommandProps<AddUsernameCommand>) {
super(props);
this.userId = props.userId;
this.username = props.username;
}
}

View File

@ -0,0 +1,52 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
AggregateID,
ConflictException,
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import {
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
} from '@modules/authentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port';
import { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { AddUsernameCommand } from './add-username.command';
import { UsernameRepositoryPort } from '../../ports/username.repository.port';
import { UsernameEntity } from '@modules/authentication/core/domain/username.entity';
@CommandHandler(AddUsernameCommand)
export class AddUsernameService implements ICommandHandler {
constructor(
@Inject(AUTHENTICATION_REPOSITORY)
private readonly authenticationRepository: AuthenticationRepositoryPort,
@Inject(USERNAME_REPOSITORY)
private readonly usernameRepository: UsernameRepositoryPort,
) {}
async execute(command: AddUsernameCommand): Promise<AggregateID> {
await this.authenticationRepository.findOneById(command.userId, {
usernames: true,
});
try {
const newUsername = await UsernameEntity.create({
name: command.username.name,
userId: command.userId,
type: command.username.type,
});
await this.usernameRepository.insert(newUsername);
return newUsername.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new UsernameAlreadyExistsException(error);
}
if (
error instanceof UniqueConstraintException &&
error.message.includes('username')
) {
throw new UsernameAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@ -1,5 +1,5 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { Username } from '../types/username';
import { Username } from '../../types/username';
export class CreateAuthenticationCommand extends Command {
readonly userId: string;

View File

@ -6,19 +6,19 @@ import {
UniqueConstraintException,
} from '@mobicoop/ddd-library';
import { CreateAuthenticationCommand } from './create-authentication.command';
import { AUTHENTICATION_REPOSITORY } from '@modules/newauthentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../ports/authentication.repository.port';
import { AuthenticationEntity } from '@modules/newauthentication/core/domain/authentication.entity';
import { AUTHENTICATION_REPOSITORY } from '@modules/authentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port';
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
import {
AuthenticationAlreadyExistsException,
UsernameAlreadyExistsException,
} from '@modules/newauthentication/core/domain/authentication.errors';
} from '@modules/authentication/core/domain/authentication.errors';
@CommandHandler(CreateAuthenticationCommand)
export class CreateAuthenticationService implements ICommandHandler {
constructor(
@Inject(AUTHENTICATION_REPOSITORY)
private readonly repository: AuthenticationRepositoryPort,
private readonly authenticationRepository: AuthenticationRepositoryPort,
) {}
async execute(command: CreateAuthenticationCommand): Promise<AggregateID> {
@ -29,7 +29,7 @@ export class CreateAuthenticationService implements ICommandHandler {
usernames: command.usernames,
});
try {
await this.repository.insert(authentication);
await this.authenticationRepository.insert(authentication);
return authentication.getProps().userId;
} catch (error: any) {
if (error instanceof ConflictException) {

View File

@ -1,9 +1,9 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DeleteAuthenticationCommand } from './delete-authentication.command';
import { AUTHENTICATION_REPOSITORY } from '@modules/newauthentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../ports/authentication.repository.port';
import { AuthenticationEntity } from '@modules/newauthentication/core/domain/authentication.entity';
import { AUTHENTICATION_REPOSITORY } from '@modules/authentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port';
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
@CommandHandler(DeleteAuthenticationCommand)
export class DeleteAuthenticationService implements ICommandHandler {
@ -18,9 +18,9 @@ export class DeleteAuthenticationService implements ICommandHandler {
usernames: true,
});
authentication.delete();
const deleted: boolean = await this.authenticationRepository.delete(
const isDeleted: boolean = await this.authenticationRepository.delete(
authentication,
);
return deleted;
return isDeleted;
}
}

View File

@ -1,4 +1,4 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { AuthenticationEntity } from '@modules/newauthentication/core/domain/authentication.entity';
import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity';
export type AuthenticationRepositoryPort = RepositoryPort<AuthenticationEntity>;

View File

@ -0,0 +1,4 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { UsernameEntity } from '../../domain/username.entity';
export type UsernameRepositoryPort = RepositoryPort<UsernameEntity>;

View File

@ -0,0 +1,6 @@
import { Type } from '@modules/authentication/core/domain/username.types';
export type Username = {
name: string;
type: Type;
};

View File

@ -4,7 +4,7 @@ import {
AuthenticationProps,
CreateAuthenticationProps,
} from './authentication.types';
import { AuthenticationCreatedDomainEvent } from './events/authentication-created.domain-events';
import { AuthenticationCreatedDomainEvent } from './events/authentication-created.domain-event';
import { AuthenticationDeletedDomainEvent } from './events/authentication-deleted.domain-event';
export class AuthenticationEntity extends AggregateRoot<AuthenticationProps> {

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class UsernameAddedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<UsernameAddedDomainEvent>) {
super(props);
}
}

View File

@ -0,0 +1,27 @@
import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library';
import { UsernameProps } from './username.types';
import { UsernameAddedDomainEvent } from './events/username-added.domain-event';
export class UsernameEntity extends AggregateRoot<UsernameProps> {
protected readonly _id: AggregateID;
static create = async (create: UsernameProps): Promise<UsernameEntity> => {
const props: UsernameProps = { ...create };
const username = new UsernameEntity({
id: props.name,
props: {
name: props.name,
userId: props.userId,
type: props.type,
},
});
username.addEvent(
new UsernameAddedDomainEvent({ aggregateId: props.name }),
);
return username;
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -1,6 +1,7 @@
// All properties that a Username has
export interface UsernameProps {
name: string;
userId?: string;
type: Type;
}

View File

@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LoggerBase,
@ -6,7 +6,7 @@ import {
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { AuthenticationEntity } from '../core/domain/authentication.entity';
import { AuthenticationRepositoryPort } from '../core/application/commands/ports/authentication.repository.port';
import { AuthenticationRepositoryPort } from '../core/application/ports/authentication.repository.port';
import { PrismaService } from './prisma.service';
import { AuthenticationMapper } from '../authentication.mapper';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
@ -57,12 +57,11 @@ export class AuthenticationRepository
prisma,
mapper,
eventEmitter,
new LoggerBase(
AuthenticationRepository.name,
'logging',
'auth',
new LoggerBase({
logger: new Logger(AuthenticationRepository.name),
domain: 'auth',
messagePublisher,
),
}),
);
}
}

View File

@ -0,0 +1,49 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LoggerBase,
MessagePublisherPort,
PrismaRepositoryBase,
} from '@mobicoop/ddd-library';
import { PrismaService } from './prisma.service';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { UsernameEntity } from '../core/domain/username.entity';
import { UsernameRepositoryPort } from '../core/application/ports/username.repository.port';
import { UsernameMapper } from '../username.mapper';
export type UsernameModel = {
username: string;
authUuid: string;
type: string;
createdAt: Date;
updatedAt: Date;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class UsernameRepository
extends PrismaRepositoryBase<UsernameEntity, UsernameModel, UsernameModel>
implements UsernameRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: UsernameMapper,
eventEmitter: EventEmitter2,
@Inject(MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort,
) {
super(
prisma.username,
prisma,
mapper,
eventEmitter,
new LoggerBase({
logger: new Logger(UsernameRepository.name),
domain: 'auth',
messagePublisher,
}),
);
}
}

View File

@ -0,0 +1,3 @@
import { ResponseBase } from '@mobicoop/ddd-library';
export class UsernameResponseDto extends ResponseBase {}

View File

@ -0,0 +1,49 @@
import {
AggregateID,
IdResponse,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { AddUsernameRequestDto } from './dtos/add-username.request.dto';
import { AddUsernameCommand } from '@modules/authentication/core/application/commands/add-usernames/add-username.command';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class AddUsernameGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AuthenticationService', 'AddUsername')
async addUsername(data: AddUsernameRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new AddUsernameCommand({
userId: data.userId,
username: {
name: data.name,
type: data.type,
},
}),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (error instanceof UsernameAlreadyExistsException)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -8,8 +8,8 @@ import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { CreateAuthenticationRequestDto } from './dtos/create-authentication.request.dto';
import { CreateAuthenticationCommand } from '@modules/newauthentication/core/application/commands/create-authentication/create-authentication.command';
import { AuthenticationAlreadyExistsException } from '@modules/newauthentication/core/domain/authentication.errors';
import { CreateAuthenticationCommand } from '@modules/authentication/core/application/commands/create-authentication/create-authentication.command';
import { AuthenticationAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
@UsePipes(
new RpcValidationPipe({

View File

@ -7,7 +7,7 @@ import {
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { DeleteAuthenticationCommand } from '@modules/newauthentication/core/application/commands/delete-authentication/delete-authentication.command';
import { DeleteAuthenticationCommand } from '@modules/authentication/core/application/commands/delete-authentication/delete-authentication.command';
import { DeleteAuthenticationRequestDto } from './dtos/delete-authentication.request.dto';
@UsePipes(

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { UsernameDto } from './username.dto';
export class AddUsernameRequestDto extends UsernameDto {
@IsString()
@IsNotEmpty()
userId: string;
}

View File

@ -0,0 +1,16 @@
import { Type } from '@modules/authentication/core/domain/username.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { IsValidUsername } from './validators/decorators/is-valid-username.decorator';
export class UsernameDto {
@IsString()
@IsNotEmpty()
@IsValidUsername({
message: 'Invalid username',
})
name: string;
@IsEnum(Type)
@IsNotEmpty()
type: Type;
}

View File

@ -0,0 +1,32 @@
import { Type } from '@modules/authentication/core/domain/username.types';
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
isEmail,
isPhoneNumber,
} from 'class-validator';
export function IsValidUsername(validationOptions?: ValidationOptions) {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'isValidUsername',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const usernameType: Type = args.object['type'];
switch (usernameType) {
case Type.PHONE:
return isPhoneNumber(value);
case Type.EMAIL:
return isEmail(value);
default:
return false;
}
},
},
});
};
}

View File

@ -0,0 +1,89 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { UsernameAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { Type } from '@modules/authentication/core/domain/username.types';
import { AddUsernameGrpcController } from '@modules/authentication/interface/grpc-controllers/add-username.grpc.controller';
import { AddUsernameRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/add-username.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const addUsernameRequest: AddUsernameRequestDto = {
userId: '78153e03-4861-4f58-a705-88526efee53b',
name: 'john.doe@email.com',
type: Type.EMAIL,
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => 'john.doe@email.com')
.mockImplementationOnce(() => {
throw new UsernameAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Add Username Grpc Controller', () => {
let addUsernameGrpcController: AddUsernameGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
AddUsernameGrpcController,
],
}).compile();
addUsernameGrpcController = module.get<AddUsernameGrpcController>(
AddUsernameGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(addUsernameGrpcController).toBeDefined();
});
it('should add a new username', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await addUsernameGrpcController.addUsername(
addUsernameRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('john.doe@email.com');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if username already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await addUsernameGrpcController.addUsername(addUsernameRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await addUsernameGrpcController.addUsername(addUsernameRequest);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,99 @@
import { IdResponse } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { AuthenticationAlreadyExistsException } from '@modules/authentication/core/domain/authentication.errors';
import { Type } from '@modules/authentication/core/domain/username.types';
import { CreateAuthenticationGrpcController } from '@modules/authentication/interface/grpc-controllers/create-authentication.grpc.controller';
import { CreateAuthenticationRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/create-authentication.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const createAuthenticationRequest: CreateAuthenticationRequestDto = {
userId: '78153e03-4861-4f58-a705-88526efee53b',
password: 'John123',
usernames: [
{
name: 'john.doe@email.com',
type: Type.EMAIL,
},
],
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => '78153e03-4861-4f58-a705-88526efee53b')
.mockImplementationOnce(() => {
throw new AuthenticationAlreadyExistsException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Create Authentication Grpc Controller', () => {
let createAuthenticationGrpcController: CreateAuthenticationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
CreateAuthenticationGrpcController,
],
}).compile();
createAuthenticationGrpcController =
module.get<CreateAuthenticationGrpcController>(
CreateAuthenticationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(createAuthenticationGrpcController).toBeDefined();
});
it('should create a new authentication', async () => {
jest.spyOn(mockCommandBus, 'execute');
const result: IdResponse = await createAuthenticationGrpcController.create(
createAuthenticationRequest,
);
expect(result).toBeInstanceOf(IdResponse);
expect(result.id).toBe('78153e03-4861-4f58-a705-88526efee53b');
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if authentication already exists', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createAuthenticationGrpcController.create(
createAuthenticationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.ALREADY_EXISTS);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await createAuthenticationGrpcController.create(
createAuthenticationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,108 @@
import {
DatabaseErrorException,
NotFoundException,
} from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { DeleteAuthenticationGrpcController } from '@modules/authentication/interface/grpc-controllers/delete-authentication.grpc.controller';
import { DeleteAuthenticationRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/delete-authentication.request.dto';
import { CommandBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing';
const deleteAuthenticationRequest: DeleteAuthenticationRequestDto = {
userId: '78153e03-4861-4f58-a705-88526efee53b',
};
const mockCommandBus = {
execute: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new NotFoundException();
})
.mockImplementationOnce(() => {
throw new DatabaseErrorException();
})
.mockImplementationOnce(() => {
throw new Error();
}),
};
describe('Delete Authentication Grpc Controller', () => {
let deleteAuthenticationGrpcController: DeleteAuthenticationGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
DeleteAuthenticationGrpcController,
],
}).compile();
deleteAuthenticationGrpcController =
module.get<DeleteAuthenticationGrpcController>(
DeleteAuthenticationGrpcController,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(deleteAuthenticationGrpcController).toBeDefined();
});
it('should create a new authentication', async () => {
jest.spyOn(mockCommandBus, 'execute');
await deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if authentication does not exist', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.NOT_FOUND);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a dedicated RpcException if a database error occurs', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.INTERNAL);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
it('should throw a generic RpcException', async () => {
jest.spyOn(mockCommandBus, 'execute');
expect.assertions(3);
try {
await deleteAuthenticationGrpcController.delete(
deleteAuthenticationRequest,
);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,57 @@
import { Type } from '@modules/authentication/core/domain/username.types';
import { IsValidUsername } from '@modules/authentication/interface/grpc-controllers/dtos/validators/decorators/is-valid-username.decorator';
import { Validator } from 'class-validator';
describe('Username decorator', () => {
class MyClass {
@IsValidUsername({
message: 'Invalid username',
})
name: string;
type: Type;
}
it('should return a property decorator has a function', () => {
const isValidUsername = IsValidUsername();
expect(typeof isValidUsername).toBe('function');
});
it('should validate a valid phone username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = '+33611223344';
myClassInstance.type = Type.PHONE;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should validate a valid email username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = 'john.doe@email.com';
myClassInstance.type = Type.EMAIL;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(0);
});
it('should not validate an invalid phone username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = '11223344';
myClassInstance.type = Type.PHONE;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
it('should not validate an invalid email username', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = 'john.doe.email.com';
myClassInstance.type = Type.EMAIL;
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
it('should not validate if type is not set', async () => {
const myClassInstance = new MyClass();
myClassInstance.name = 'john.doe@email.com';
const validator = new Validator();
const validation = await validator.validate(myClassInstance);
expect(validation.length).toBe(1);
});
});

View File

@ -0,0 +1,49 @@
import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common';
import { Type } from './core/domain/username.types';
import { UsernameEntity } from './core/domain/username.entity';
import { UsernameModel } from './infrastructure/username.repository';
import { UsernameResponseDto } from './interface/dtos/username.response.dto';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class UsernameMapper
implements
Mapper<UsernameEntity, UsernameModel, UsernameModel, UsernameResponseDto>
{
toPersistence = (entity: UsernameEntity): UsernameModel => {
const copy = entity.getProps();
const record: UsernameModel = {
authUuid: copy.userId,
username: copy.name,
type: copy.type,
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: UsernameModel): UsernameEntity => {
const entity = new UsernameEntity({
id: record.authUuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
name: record.username,
type: Type[record.type],
},
});
return entity;
};
toResponse = (entity: UsernameEntity): UsernameResponseDto => {
const response = new UsernameResponseDto(entity);
return response;
};
}

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthenticationRepository } from '../authentication/adapters/secondaries/authentication.repository';
import { UsernameRepository } from '../authentication/adapters/secondaries/username.repository';
import { AuthenticationRepository } from '../oldauthentication/adapters/secondaries/authentication.repository';
import { UsernameRepository } from '../oldauthentication/adapters/secondaries/username.repository';
import { PrismaService } from './adapters/secondaries/prisma-service';
@Module({

View File

@ -4,7 +4,7 @@ import {
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { AuthenticationRepository } from '../../../authentication/adapters/secondaries/authentication.repository';
import { AuthenticationRepository } from '../../../oldauthentication/adapters/secondaries/authentication.repository';
@Injectable()
export class PrismaHealthIndicatorUseCase extends HealthIndicator {

View File

@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthServerController } from './adapters/primaries/health-server.controller';
import { PrismaHealthIndicatorUseCase } from './domain/usecases/prisma.health-indicator.usecase';
import { AuthenticationRepository } from '../authentication/adapters/secondaries/authentication.repository';
import { AuthenticationRepository } from '../oldauthentication/adapters/secondaries/authentication.repository';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './adapters/primaries/health.controller';
import { TerminusModule } from '@nestjs/terminus';

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { AuthenticationRepository } from '../../../authentication/adapters/secondaries/authentication.repository';
import { AuthenticationRepository } from '../../../oldauthentication/adapters/secondaries/authentication.repository';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';

View File

@ -1,54 +0,0 @@
import { Module, Provider } from '@nestjs/common';
import { CreateAuthenticationGrpcController } from './interface/grpc-controllers/create-authentication.grpc.controller';
import { CreateAuthenticationService } from './core/application/commands/create-authentication/create-authentication.service';
import { AuthenticationMapper } from './authentication.mapper';
import { AUTHENTICATION_REPOSITORY } from './authentication.di-tokens';
import { AuthenticationRepository } from './infrastructure/authentication.repository';
import { PrismaService } from './infrastructure/prisma.service';
import { CqrsModule } from '@nestjs/cqrs';
import { DeleteAuthenticationGrpcController } from './interface/grpc-controllers/delete-authentication.grpc.controller';
import { DeleteAuthenticationService } from './core/application/commands/delete-authentication/delete-authentication.service';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
const grpcControllers = [
CreateAuthenticationGrpcController,
DeleteAuthenticationGrpcController,
];
const commandHandlers: Provider[] = [
CreateAuthenticationService,
DeleteAuthenticationService,
];
const mappers: Provider[] = [AuthenticationMapper];
const repositories: Provider[] = [
{
provide: AUTHENTICATION_REPOSITORY,
useClass: AuthenticationRepository,
},
];
const messageBrokers: Provider[] = [
{
provide: MESSAGE_PUBLISHER,
useClass: MessageBrokerPublisher,
},
];
const orms: Provider[] = [PrismaService];
@Module({
imports: [CqrsModule],
controllers: [...grpcControllers],
providers: [
...commandHandlers,
...mappers,
...repositories,
...messageBrokers,
...orms,
],
exports: [PrismaService, AuthenticationMapper, AUTHENTICATION_REPOSITORY],
})
export class AuthenticationModule {}

View File

@ -1,6 +0,0 @@
import { Type } from '@modules/newauthentication/core/domain/username.types';
export type Username = {
name: string;
type: Type;
};

View File

@ -1,11 +0,0 @@
import { Type } from '@modules/newauthentication/core/domain/username.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class UsernameDto {
@IsString()
name: string;
@IsEnum(Type)
@IsNotEmpty()
type: Type;
}

View File

@ -0,0 +1,66 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { DatabaseModule } from '../database/database.module';
import { AuthenticationController } from './adapters/primaries/authentication.controller';
import { CreateAuthenticationUseCase } from './domain/usecases/create-authentication.usecase';
import { ValidateAuthenticationUseCase } from './domain/usecases/validate-authentication.usecase';
import { AuthenticationProfile } from './mappers/authentication.profile';
import { AuthenticationRepository } from './adapters/secondaries/authentication.repository';
import { UpdateUsernameUseCase } from './domain/usecases/update-username.usecase';
import { UsernameProfile } from './mappers/username.profile';
import { AddUsernameUseCase } from './domain/usecases/add-username.usecase';
import { UpdatePasswordUseCase } from './domain/usecases/update-password.usecase';
import { DeleteUsernameUseCase } from './domain/usecases/delete-username.usecase';
import { DeleteAuthenticationUseCase } from './domain/usecases/delete-authentication.usecase';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthenticationMessagerController } from './adapters/primaries/authentication-messager.controller';
import { Messager } from './adapters/secondaries/messager';
@Module({
imports: [
DatabaseModule,
CqrsModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
handlers: {
userUpdate: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'user.update',
},
userDelete: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'user.delete',
},
},
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
enableControllerDiscovery: true,
}),
inject: [ConfigService],
}),
],
controllers: [AuthenticationController, AuthenticationMessagerController],
providers: [
AuthenticationProfile,
UsernameProfile,
AuthenticationRepository,
Messager,
ValidateAuthenticationUseCase,
CreateAuthenticationUseCase,
AddUsernameUseCase,
UpdateUsernameUseCase,
UpdatePasswordUseCase,
DeleteUsernameUseCase,
DeleteAuthenticationUseCase,
],
exports: [],
})
export class AuthenticationModule {}

View File

@ -1,5 +1,5 @@
import { ArgumentMetadata } from '@nestjs/common';
import { ValidateAuthenticationRequest } from '../../../modules/authentication/domain/dtos/validate-authentication.request';
import { ValidateAuthenticationRequest } from '../../../modules/oldauthentication/domain/dtos/validate-authentication.request';
import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe';
describe('RpcValidationPipe', () => {