authorization module

This commit is contained in:
sbriat
2023-07-12 10:39:55 +02:00
parent ced57e35a6
commit d78a065c54
106 changed files with 847 additions and 3654 deletions

View File

@@ -1,35 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
HealthCheckResult,
} from '@nestjs/terminus';
import { Messager } from '../secondaries/messager';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
// this controller responds to rest GET /health
@Controller('health')
export class HealthController {
constructor(
private readonly prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
private healthCheckService: HealthCheckService,
private messager: Messager,
) {}
@Get()
@HealthCheck()
async check() {
try {
return await this.healthCheckService.check([
async () => this.prismaHealthIndicatorUseCase.isHealthy('prisma'),
]);
} catch (error) {
const healthCheckResult: HealthCheckResult = error.response;
this.messager.publish(
'logging.auth.health.crit',
JSON.stringify(healthCheckResult.error),
);
throw error;
}
}
}

View File

@@ -1,12 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export abstract class IMessageBroker {
exchange: string;
constructor(exchange: string) {
this.exchange = exchange;
}
abstract publish(routingKey: string, message: string): void;
}

View File

@@ -1,18 +0,0 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IMessageBroker } from './message-broker';
@Injectable()
export class Messager extends IMessageBroker {
constructor(
private readonly amqpConnection: AmqpConnection,
configService: ConfigService,
) {
super(configService.get<string>('RMQ_EXCHANGE'));
}
publish = (routingKey: string, message: string): void => {
this.amqpConnection.publish(this.exchange, routingKey, message);
};
}

View File

@@ -0,0 +1,3 @@
export interface CheckRepositoryPort {
healthCheck(): Promise<boolean>;
}

View File

@@ -0,0 +1,54 @@
import { Inject, Injectable } from '@nestjs/common';
import {
HealthCheckError,
HealthCheckResult,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { CheckRepositoryPort } from '../ports/check-repository.port';
import {
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
} from '@modules/health/health.di-tokens';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { LOGGING_AUTHENTICATION_HEALTH_CRIT } from '@modules/health/health.constants';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { AuthenticationRepositoryPort } from '@modules/authentication/core/application/ports/authentication.repository.port';
import { UsernameRepositoryPort } from '@modules/authentication/core/application/ports/username.repository.port';
@Injectable()
export class RepositoriesHealthIndicatorUseCase extends HealthIndicator {
private _checkRepositories: CheckRepositoryPort[];
constructor(
@Inject(AUTHENTICATION_REPOSITORY)
private readonly authenticationRepository: AuthenticationRepositoryPort,
@Inject(USERNAME_REPOSITORY)
private readonly usernameRepository: UsernameRepositoryPort,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {
super();
this._checkRepositories = [authenticationRepository, usernameRepository];
}
isHealthy = async (key: string): Promise<HealthIndicatorResult> => {
try {
await Promise.all(
this._checkRepositories.map(
async (checkRepository: CheckRepositoryPort) => {
await checkRepository.healthCheck();
},
),
);
return this.getStatus(key, true);
} catch (error) {
const healthCheckResult: HealthCheckResult = error;
this.messagePublisher.publish(
LOGGING_AUTHENTICATION_HEALTH_CRIT,
JSON.stringify(healthCheckResult.error),
);
throw new HealthCheckError('Repository', {
repository: error.message,
});
}
};
}

View File

@@ -1,25 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { AuthenticationRepository } from '../../../oldauthentication/adapters/secondaries/authentication.repository';
@Injectable()
export class PrismaHealthIndicatorUseCase extends HealthIndicator {
constructor(private readonly repository: AuthenticationRepository) {
super();
}
isHealthy = async (key: string): Promise<HealthIndicatorResult> => {
try {
await this.repository.healthCheck();
return this.getStatus(key, true);
} catch (e) {
throw new HealthCheckError('Prisma', {
prisma: e.message,
});
}
};
}

View File

@@ -0,0 +1 @@
export const LOGGING_AUTHENTICATION_HEALTH_CRIT = 'logging.auth.health.crit';

View File

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

View File

@@ -1,34 +1,45 @@
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 '../oldauthentication/adapters/secondaries/authentication.repository';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './adapters/primaries/health.controller';
import { Module, Provider } from '@nestjs/common';
import { HealthHttpController } from './interface/http-controllers/health.http.controller';
import { TerminusModule } from '@nestjs/terminus';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Messager } from './adapters/secondaries/messager';
import { RepositoriesHealthIndicatorUseCase } from './core/application/usecases/repositories.health-indicator.usecase';
import { HealthGrpcController } from './interface/grpc-controllers/health.grpc.controller';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { AuthenticationRepository } from '@modules/authentication/infrastructure/authentication.repository';
import {
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
} from './health.di-tokens';
import { UsernameRepository } from '@modules/authentication/infrastructure/username.repository';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { AuthenticationModule } from '@modules/authentication/authentication.module';
const grpcControllers = [HealthGrpcController];
const httpControllers = [HealthHttpController];
const useCases: Provider[] = [RepositoriesHealthIndicatorUseCase];
const repositories: Provider[] = [
{
provide: AUTHENTICATION_REPOSITORY,
useClass: AuthenticationRepository,
},
{
provide: USERNAME_REPOSITORY,
useClass: UsernameRepository,
},
];
const messageBrokers: Provider[] = [
{
provide: MESSAGE_PUBLISHER,
useClass: MessageBrokerPublisher,
},
];
@Module({
imports: [
TerminusModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
}),
inject: [ConfigService],
}),
DatabaseModule,
],
controllers: [HealthServerController, HealthController],
providers: [PrismaHealthIndicatorUseCase, AuthenticationRepository, Messager],
imports: [TerminusModule, AuthenticationModule],
controllers: [...grpcControllers, ...httpControllers],
providers: [...useCases, ...repositories, ...messageBrokers],
})
export class HealthModule {}

View File

@@ -1,8 +1,8 @@
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { RepositoriesHealthIndicatorUseCase } from '../../core/application/usecases/repositories.health-indicator.usecase';
enum ServingStatus {
export enum ServingStatus {
UNKNOWN = 0,
SERVING = 1,
NOT_SERVING = 2,
@@ -16,26 +16,25 @@ interface HealthCheckResponse {
status: ServingStatus;
}
// this controller responds to gRPC health check service
@Controller()
export class HealthServerController {
export class HealthGrpcController {
constructor(
private readonly prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
private readonly repositoriesHealthIndicatorUseCase: RepositoriesHealthIndicatorUseCase,
) {}
@GrpcMethod('Health', 'Check')
async check(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data: HealthCheckRequest,
data?: HealthCheckRequest,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
metadata: any,
metadata?: any,
): Promise<HealthCheckResponse> {
const healthCheck = await this.prismaHealthIndicatorUseCase.isHealthy(
'prisma',
const healthCheck = await this.repositoriesHealthIndicatorUseCase.isHealthy(
'repositories',
);
return {
status:
healthCheck['prisma'].status == 'up'
healthCheck['repositories'].status == 'up'
? ServingStatus.SERVING
: ServingStatus.NOT_SERVING,
};

View File

@@ -0,0 +1,28 @@
import { RepositoriesHealthIndicatorUseCase } from '@modules/health/core/application/usecases/repositories.health-indicator.usecase';
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
HealthCheckResult,
} from '@nestjs/terminus';
@Controller('health')
export class HealthHttpController {
constructor(
private readonly repositoriesHealthIndicatorUseCase: RepositoriesHealthIndicatorUseCase,
private readonly healthCheckService: HealthCheckService,
) {}
@Get()
@HealthCheck()
async check(): Promise<HealthCheckResult> {
try {
return await this.healthCheckService.check([
async () =>
this.repositoriesHealthIndicatorUseCase.isHealthy('repositories'),
]);
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,72 @@
import { RepositoriesHealthIndicatorUseCase } from '@modules/health/core/application/usecases/repositories.health-indicator.usecase';
import {
HealthGrpcController,
ServingStatus,
} from '@modules/health/interface/grpc-controllers/health.grpc.controller';
import { Test, TestingModule } from '@nestjs/testing';
const mockRepositoriesHealthIndicatorUseCase = {
isHealthy: jest
.fn()
.mockImplementationOnce(() => ({
repositories: {
status: 'up',
},
}))
.mockImplementationOnce(() => ({
repositories: {
status: 'down',
},
})),
};
describe('Health Grpc Controller', () => {
let healthGrpcController: HealthGrpcController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: RepositoriesHealthIndicatorUseCase,
useValue: mockRepositoriesHealthIndicatorUseCase,
},
HealthGrpcController,
],
}).compile();
healthGrpcController =
module.get<HealthGrpcController>(HealthGrpcController);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(healthGrpcController).toBeDefined();
});
it('should return a Serving status ', async () => {
jest.spyOn(mockRepositoriesHealthIndicatorUseCase, 'isHealthy');
const servingStatus: { status: ServingStatus } =
await healthGrpcController.check();
expect(servingStatus).toEqual({
status: ServingStatus.SERVING,
});
expect(
mockRepositoriesHealthIndicatorUseCase.isHealthy,
).toHaveBeenCalledTimes(1);
});
it('should return a Not Serving status ', async () => {
jest.spyOn(mockRepositoriesHealthIndicatorUseCase, 'isHealthy');
const servingStatus: { status: ServingStatus } =
await healthGrpcController.check();
expect(servingStatus).toEqual({
status: ServingStatus.NOT_SERVING,
});
expect(
mockRepositoriesHealthIndicatorUseCase.isHealthy,
).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,90 @@
import { RepositoriesHealthIndicatorUseCase } from '@modules/health/core/application/usecases/repositories.health-indicator.usecase';
import { HealthHttpController } from '@modules/health/interface/http-controllers/health.http.controller';
import { HealthCheckResult, HealthCheckService } from '@nestjs/terminus';
import { Test, TestingModule } from '@nestjs/testing';
const mockHealthCheckService = {
check: jest
.fn()
.mockImplementationOnce(() => ({
status: 'ok',
info: {
repositories: {
status: 'up',
},
},
error: {},
details: {
repositories: {
status: 'up',
},
},
}))
.mockImplementationOnce(() => ({
status: 'error',
info: {},
error: {
repository:
"\nInvalid `prisma.$queryRaw()` invocation:\n\n\nCan't reach database server at `v3-db`:`5432`\n\nPlease make sure your database server is running at `v3-db`:`5432`.",
},
details: {
repository:
"\nInvalid `prisma.$queryRaw()` invocation:\n\n\nCan't reach database server at `v3-db`:`5432`\n\nPlease make sure your database server is running at `v3-db`:`5432`.",
},
})),
};
const mockRepositoriesHealthIndicatorUseCase = {
isHealthy: jest.fn(),
};
describe('Health Http Controller', () => {
let healthHttpController: HealthHttpController;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: HealthCheckService,
useValue: mockHealthCheckService,
},
{
provide: RepositoriesHealthIndicatorUseCase,
useValue: mockRepositoriesHealthIndicatorUseCase,
},
HealthHttpController,
],
}).compile();
healthHttpController =
module.get<HealthHttpController>(HealthHttpController);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(healthHttpController).toBeDefined();
});
it('should return an HealthCheckResult with Ok status ', async () => {
jest.spyOn(mockHealthCheckService, 'check');
jest.spyOn(mockRepositoriesHealthIndicatorUseCase, 'isHealthy');
const healthCheckResult: HealthCheckResult =
await healthHttpController.check();
expect(healthCheckResult.status).toBe('ok');
expect(mockHealthCheckService.check).toHaveBeenCalledTimes(1);
});
it('should return an HealthCheckResult with Error status ', async () => {
jest.spyOn(mockHealthCheckService, 'check');
jest.spyOn(mockRepositoriesHealthIndicatorUseCase, 'isHealthy');
const healthCheckResult: HealthCheckResult =
await healthHttpController.check();
expect(healthCheckResult.status).toBe('error');
expect(mockHealthCheckService.check).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,47 +0,0 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../adapters/secondaries/messager';
const mockAmqpConnection = {
publish: jest.fn().mockImplementation(),
};
const mockConfigService = {
get: jest.fn().mockResolvedValue({
RMQ_EXCHANGE: 'mobicoop',
}),
};
describe('Messager', () => {
let messager: Messager;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
Messager,
{
provide: AmqpConnection,
useValue: mockAmqpConnection,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
messager = module.get<Messager>(Messager);
});
it('should be defined', () => {
expect(messager).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockAmqpConnection, 'publish');
messager.publish('test.create.info', 'my-test');
expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,58 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { AuthenticationRepository } from '../../../oldauthentication/adapters/secondaries/authentication.repository';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
const mockAuthenticationRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new PrismaClientKnownRequestError('Service unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
};
describe('PrismaHealthIndicatorUseCase', () => {
let prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AuthenticationRepository,
useValue: mockAuthenticationRepository,
},
PrismaHealthIndicatorUseCase,
],
}).compile();
prismaHealthIndicatorUseCase = module.get<PrismaHealthIndicatorUseCase>(
PrismaHealthIndicatorUseCase,
);
});
it('should be defined', () => {
expect(prismaHealthIndicatorUseCase).toBeDefined();
});
describe('execute', () => {
it('should check health successfully', async () => {
const healthIndicatorResult: HealthIndicatorResult =
await prismaHealthIndicatorUseCase.isHealthy('prisma');
expect(healthIndicatorResult['prisma'].status).toBe('up');
});
it('should throw an error if database is unavailable', async () => {
await expect(
prismaHealthIndicatorUseCase.isHealthy('prisma'),
).rejects.toBeInstanceOf(HealthCheckError);
});
});
});

View File

@@ -0,0 +1,84 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
import { RepositoriesHealthIndicatorUseCase } from '../../core/application/usecases/repositories.health-indicator.usecase';
import { MESSAGE_PUBLISHER } from '@src/app.di-tokens';
import { DatabaseErrorException } from '@mobicoop/ddd-library';
import {
AUTHENTICATION_REPOSITORY,
USERNAME_REPOSITORY,
} from '@modules/health/health.di-tokens';
const mockAuthenticationRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new DatabaseErrorException('An error occured in the database');
}),
};
const mockUsernameRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new DatabaseErrorException('An error occured in the database');
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('RepositoriesHealthIndicatorUseCase', () => {
let repositoriesHealthIndicatorUseCase: RepositoriesHealthIndicatorUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RepositoriesHealthIndicatorUseCase,
{
provide: AUTHENTICATION_REPOSITORY,
useValue: mockAuthenticationRepository,
},
{
provide: USERNAME_REPOSITORY,
useValue: mockUsernameRepository,
},
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
],
}).compile();
repositoriesHealthIndicatorUseCase =
module.get<RepositoriesHealthIndicatorUseCase>(
RepositoriesHealthIndicatorUseCase,
);
});
it('should be defined', () => {
expect(repositoriesHealthIndicatorUseCase).toBeDefined();
});
describe('execute', () => {
it('should check health successfully', async () => {
const healthIndicatorResult: HealthIndicatorResult =
await repositoriesHealthIndicatorUseCase.isHealthy('repositories');
expect(healthIndicatorResult['repositories'].status).toBe('up');
});
it('should throw an error if database is unavailable', async () => {
jest.spyOn(mockMessagePublisher, 'publish');
await expect(
repositoriesHealthIndicatorUseCase.isHealthy('repositories'),
).rejects.toBeInstanceOf(HealthCheckError);
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
});
});
});