Merge pull request #1 from Cameleonapp/feature/keycloak-config

Feature/keycloak config
This commit is contained in:
KurtisMelkisedec 2025-10-30 23:30:43 +00:00 committed by GitHub
commit eb83c8dc1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 984 additions and 46 deletions

712
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,8 @@
"amqplib": "^0.10.9", "amqplib": "^0.10.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"keycloak-connect": "^26.1.1",
"nest-keycloak-connect": "^1.10.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1" "swagger-ui-express": "^5.0.1"

View File

@ -4,14 +4,55 @@ import { AppService } from './app.service';
import { WebhookController } from './controllers/webhook.controller'; import { WebhookController } from './controllers/webhook.controller';
import { WebhookService } from './services/webhook.service'; import { WebhookService } from './services/webhook.service';
import { RabbitMQService } from './services/rabbit.service'; import { RabbitMQService } from './services/rabbit.service';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import rabbitmqConfig from './config/rabbitmq.config'; import appConfig from './config/app.config';
import {
AuthGuard,
KeycloakConnectModule,
PolicyEnforcementMode,
ResourceGuard,
RoleGuard,
TokenValidation,
} from 'nest-keycloak-connect';
import { APP_GUARD } from '@nestjs/core';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true,load: [rabbitmqConfig] }), KeycloakConnectModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
const keycloakConfig = configService.get('appConfig.keycloak');
return {
authServerUrl: keycloakConfig.authServerUrl,
realm: keycloakConfig.realm,
clientId: keycloakConfig.clientId,
secret: keycloakConfig.clientSecret,
policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
tokenValidation: TokenValidation.ONLINE,
};
},
inject: [ConfigService],
}),
ConfigModule.forRoot({ isGlobal: true, load: [appConfig] }),
], ],
controllers: [AppController, WebhookController], controllers: [AppController, WebhookController],
providers: [AppService, WebhookService, RabbitMQService], providers: [
AppService,
WebhookService,
RabbitMQService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
{
provide: APP_GUARD,
useClass: ResourceGuard,
},
{
provide: APP_GUARD,
useClass: RoleGuard,
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,6 +1,6 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export default registerAs('rabbitmq', () => ({ export default registerAs('appConfig', () => ({
user: process.env.RABBITMQ_USER, user: process.env.RABBITMQ_USER,
pass: process.env.RABBITMQ_PASS, pass: process.env.RABBITMQ_PASS,
host: process.env.RABBITMQ_HOST, host: process.env.RABBITMQ_HOST,
@ -11,4 +11,10 @@ export default registerAs('rabbitmq', () => ({
subscription: process.env.RABBITMQ_QUEUE_PAYMENT || 'subscription_queue', subscription: process.env.RABBITMQ_QUEUE_PAYMENT || 'subscription_queue',
he: process.env.RABBITMQ_QUEUE_NOTIFICATION || 'he_queue', he: process.env.RABBITMQ_QUEUE_NOTIFICATION || 'he_queue',
}, },
keycloak: {
authServerUrl: process.env.KEYCLOAK_AUTH_SERVER_URL,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
realm: process.env.KEYCLOAK_REALM,
},
})); }));

View File

@ -10,18 +10,40 @@ import {
HttpStatus, HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import {
ApiBearerAuth,
ApiBody,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { Resource, Roles } from 'nest-keycloak-connect';
import { InboundSMSMessageNotificationWrapperDto } from 'src/dtos/sms.mo.dto'; import { InboundSMSMessageNotificationWrapperDto } from 'src/dtos/sms.mo.dto';
import { SubscriptionDto } from 'src/dtos/subscription.dto'; import { SubscriptionDto } from 'src/dtos/subscription.dto';
import { WebhookService } from 'src/services/webhook.service'; import { WebhookService } from 'src/services/webhook.service';
@Controller('webhook') @Controller('webhook')
@ApiTags('webhook') @ApiTags('webhook')
@ApiBearerAuth()
export class WebhookController { export class WebhookController {
constructor(private readonly webhookService: WebhookService) {} constructor(private readonly webhookService: WebhookService) {}
@Post('sms-mo/:operator/:country') @Post('sms-mo/:operator/:country')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@Roles({ roles: ['admin_webhook'] })
@ApiOperation({ summary: 'Receive callback for SMS MO notification' })
@ApiBody({ type: InboundSMSMessageNotificationWrapperDto })
@ApiCreatedResponse({
description: 'SMS MO callback successfully queued',
schema: {
example: {
status: 'queued',
operator: 'Orange',
country: 'SN',
},
},
})
async smsMoNotification( async smsMoNotification(
@Param('country') country: string, @Param('country') country: string,
@Param('operator') operator: string, @Param('operator') operator: string,
@ -38,6 +60,19 @@ export class WebhookController {
@Post('subscription/:operator/:country') @Post('subscription/:operator/:country')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@Roles({ roles: ['admin_webhook'] })
@ApiOperation({ summary: 'Receive callback for management of subscription' })
@ApiBody({ type: SubscriptionDto })
@ApiCreatedResponse({
description: 'Subscription event successfully queued',
schema: {
example: {
status: 'queued',
operator: 'Orange',
country: 'EG',
},
},
})
async manageSubscription( async manageSubscription(
@Param('country') country: string, @Param('country') country: string,
@Param('operator') operator: string, @Param('operator') operator: string,
@ -53,6 +88,16 @@ export class WebhookController {
@Get('he/:operator/:country') @Get('he/:operator/:country')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Roles({ roles: ['admin_webhook'] })
@ApiOperation({ summary: 'Receive callback for HE notification' })
@ApiOkResponse({
description: 'HE notification successfully queued',
schema: {
example: {
status: 'queued',
},
},
})
async heNotification( async heNotification(
@Param('country') country: string, @Param('country') country: string,
@Param('operator') operator: string, @Param('operator') operator: string,
@ -69,6 +114,6 @@ export class WebhookController {
callback, callback,
); );
return { status: 'queued', operator, country, callback }; return { status: 'queued' };
} }
} }

View File

@ -1,38 +1,54 @@
import { IsString, ValidateNested, IsNotEmpty, IsDateString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
ValidateNested,
IsNotEmpty,
IsDateString,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
export class InboundSMSMessageDto { export class InboundSMSMessageDto {
@ApiProperty({ example: '2025-10-30T14:00:00Z' })
@IsDateString() @IsDateString()
dateTime: string; dateTime: string;
@ApiProperty({ example: '+33612345678' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
destinationAddress: string; destinationAddress: string;
@ApiProperty({ example: 'mes1234' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
messageId: string; messageId: string;
@ApiProperty({
example: 'recipient id %% The content of the message we should send.',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
message: string; message: string;
@ApiProperty({ example: 'acr:token' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
senderAddress: string; senderAddress: string;
} }
export class InboundSMSMessageNotificationDto { export class InboundSMSMessageNotificationDto {
@ApiProperty({ example: '12345' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
callbackData: string; callbackData: string;
@ApiProperty({ type: InboundSMSMessageDto })
@ValidateNested() @ValidateNested()
@Type(() => InboundSMSMessageDto) @Type(() => InboundSMSMessageDto)
inboundSMSMessage: InboundSMSMessageDto; inboundSMSMessage: InboundSMSMessageDto;
} }
export class InboundSMSMessageNotificationWrapperDto { export class InboundSMSMessageNotificationWrapperDto {
@ApiProperty({ type: InboundSMSMessageNotificationDto })
@ValidateNested() @ValidateNested()
@Type(() => InboundSMSMessageNotificationDto) @Type(() => InboundSMSMessageNotificationDto)
inboundSMSMessageNotification: InboundSMSMessageNotificationDto; inboundSMSMessageNotification: InboundSMSMessageNotificationDto;

View File

@ -16,6 +16,8 @@ async function bootstrap() {
.setDescription( .setDescription(
'This is a service dedicated to the reception of callback from external source and sending to rabbitMQ', 'This is a service dedicated to the reception of callback from external source and sending to rabbitMQ',
) )
.addBearerAuth()
.addTag('auth')
.setVersion('1.0') .setVersion('1.0')
.build(); .build();

View File

@ -6,18 +6,23 @@ import { connect, Connection, Channel } from 'amqplib';
export class RabbitMQService implements OnModuleInit { export class RabbitMQService implements OnModuleInit {
private connection!: Connection; private connection!: Connection;
private channel!: Channel; private channel!: Channel;
private maxRetry: number;
private delay: number;
constructor(private configService: ConfigService) {} constructor(private configService: ConfigService) {
this.maxRetry = 5;
this.delay = 500;
}
async onModuleInit() { async onModuleInit() {
await this.connectWithRetry(); await this.connectWithRetry();
} }
async connect(): Promise<void> { async connect(): Promise<void> {
const user = this.configService.get<string>('rabbitmq.user'); const user = this.configService.get<string>('appConfig.user');
const pass = this.configService.get<string>('rabbitmq.pass'); const pass = this.configService.get<string>('appConfig.pass');
const host = this.configService.get<string>('rabbitmq.host'); const host = this.configService.get<string>('appConfig.host');
const port = this.configService.get<string>('rabbitmq.port'); const port = this.configService.get<string>('appConfig.port');
this.connection = await connect(`amqp://${user}:${pass}@${host}:${port}`); this.connection = await connect(`amqp://${user}:${pass}@${host}:${port}`);
this.channel = await this.connection.createChannel(); this.channel = await this.connection.createChannel();
@ -39,10 +44,37 @@ export class RabbitMQService implements OnModuleInit {
throw new Error('Could not connect to RabbitMQ after multiple attempts'); throw new Error('Could not connect to RabbitMQ after multiple attempts');
} }
//this method send the message to rabbitMQ queue
async sendToQueue(queue: string, message: any) { async sendToQueue(queue: string, message: any) {
if (!this.channel) throw new Error('RabbitMQ channel not initialized'); if (!this.channel) throw new Error('RabbitMQ channel not initialized');
//check if the queue exist and create it if not
await this.channel.assertQueue(queue, { durable: true }); await this.channel.assertQueue(queue, { durable: true });
this.channel.sendToQueue(queue, Buffer.from(JSON.stringify(message)));
console.log(`Sent message to queue "${queue}"`); for (let attempt = 1; attempt <= this.maxRetry; attempt++) {
try {
const sent = await this.channel.sendToQueue(
queue,
Buffer.from(JSON.stringify(message)),
);
if (!sent) {
throw new Error('Message not sent');
}
console.log(`Message sent to queue ${queue} attempt ${attempt}`);
return;
} catch (error) {
if (attempt === this.maxRetry) {
console.log('All attempts failed');
throw error;
}
const maxDelay = Math.pow(2, attempt) * this.delay;
const delay = maxDelay / 2 + Math.random() * (maxDelay / 2);
console.warn(
`Attempt ${attempt} failed: ${error.message}. Retrying in ${Math.round(delay)}ms`,
);
await new Promise((res) => setTimeout(res, delay));
}
}
} }
} }

View File

@ -0,0 +1,117 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WebhookService } from './webhook.service';
import { RabbitMQService } from './rabbit.service';
import { ConfigService } from '@nestjs/config';
import { InboundSMSMessageNotificationWrapperDto } from 'src/dtos/sms.mo.dto';
import {
EventType,
OrderState,
SubscriptionDto,
} from 'src/dtos/subscription.dto';
describe('WebhookService', () => {
let service: WebhookService;
const mockRabbitMQ = { sendToQueue: jest.fn() };
const mockConfigService = {
get: jest.fn((key: string) => {
const config = {
RABBITMQ_QUEUE_WEBHOOK: 'RABBITMQ_QUEUE_WEBHOOK',
RABBITMQ_QUEUE_NOTIFICATION: 'RABBITMQ_QUEUE_NOTIFICATION',
RABBITMQ_QUEUE_PAYMENT: 'RABBITMQ_QUEUE_PAYMENT',
};
return config[key];
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhookService,
{ provide: RabbitMQService, useValue: mockRabbitMQ },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<WebhookService>(WebhookService);
jest.clearAllMocks();
});
//unit test for sms-mo notification
it('should send SMS MO payload to the correct queue', async () => {
const dto: InboundSMSMessageNotificationWrapperDto = {
inboundSMSMessageNotification: {
callbackData: 'cb-123',
inboundSMSMessage: {
dateTime: new Date().toISOString(),
destinationAddress: '12345',
messageId: 'msg-001',
message: 'Hello world!',
senderAddress: '987654321',
},
},
};
await service.smsMoNotification('FR', 'Orange', '123', dto);
expect(mockRabbitMQ.sendToQueue).toHaveBeenCalledWith(
'webhook_queue',
expect.objectContaining({
operator: 'Orange',
country: 'FR',
ise2: '123',
data: dto,
}),
);
});
//unit test subscription mangement
it('should send subscription payload to the correct queue', async () => {
const dto: SubscriptionDto = {
note: { text: 'User subscribed' },
event: {
id: 1,
relatedParty: [{ id: '123', name: 'ISE2', role: 'subscriber' }],
order: {
id: 1,
state: OrderState.Completed,
orderItem: {
product: { id: 'WIDO access' },
},
},
},
eventType: EventType.creation,
eventTime: new Date().toISOString(),
};
await service.manageSubscription('FR', 'Orange', dto);
expect(mockRabbitMQ.sendToQueue).toHaveBeenCalledWith(
'payment_queue',
expect.objectContaining({
operator: 'Orange',
country: 'FR',
data: dto,
}),
);
});
//unit test for he notification
it('should send HE notification payload to the correct queue', async () => {
await service.handleHeNotification(
'FR',
'Orange',
'callbackURL',
'ISE2CODE',
);
expect(mockRabbitMQ.sendToQueue).toHaveBeenCalledWith(
'notification_queue',
expect.objectContaining({
operator: 'Orange',
country: 'FR',
callback: 'callbackURL',
ise2: 'ISE2CODE',
}),
);
});
});

View File

@ -2,10 +2,22 @@ import { Injectable } from '@nestjs/common';
import { RabbitMQService } from 'src/services/rabbit.service'; import { RabbitMQService } from 'src/services/rabbit.service';
import { InboundSMSMessageNotificationWrapperDto } from '../dtos/sms.mo.dto'; import { InboundSMSMessageNotificationWrapperDto } from '../dtos/sms.mo.dto';
import { SubscriptionDto } from '../dtos/subscription.dto'; import { SubscriptionDto } from '../dtos/subscription.dto';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class WebhookService { export class WebhookService {
constructor(private readonly rabbitMQService: RabbitMQService) {} private smsMoQueue: string;
private heQueue: string;
private subscriptionEventQueue: string;
constructor(
private readonly rabbitMQService: RabbitMQService,
private configService: ConfigService,
) {
const config = this.configService.get('appConfig.queues');
this.smsMoQueue = config.smsmo as string;
this.heQueue = config.he as string;
this.subscriptionEventQueue = config.subscription as string;
}
async smsMoNotification( async smsMoNotification(
country: string, country: string,
@ -21,7 +33,7 @@ export class WebhookService {
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
}; };
await this.rabbitMQService.sendToQueue('sms_mo', payload); await this.rabbitMQService.sendToQueue(this.smsMoQueue, payload);
} }
async manageSubscription( async manageSubscription(
@ -37,7 +49,10 @@ export class WebhookService {
}; };
// send message to queue "subscription_events" // send message to queue "subscription_events"
await this.rabbitMQService.sendToQueue('subscription_events', payload); await this.rabbitMQService.sendToQueue(
this.subscriptionEventQueue,
payload,
);
console.log('payload sent to rabbitMQ'); console.log('payload sent to rabbitMQ');
} }
@ -54,7 +69,7 @@ export class WebhookService {
callback, callback,
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
}; };
await this.rabbitMQService.sendToQueue('he_notifications', payload); await this.rabbitMQService.sendToQueue(this.heQueue, payload);
console.log('payload sent to rabbitMQ'); console.log('payload sent to rabbitMQ');
} }
} }