From 052124cfb733d0fd23a3f3813f92d991850a2398 Mon Sep 17 00:00:00 2001 From: Abdulrazaq Isa Babi Date: Fri, 29 May 2026 22:43:26 +0000 Subject: [PATCH 1/2] fix: enable NestJS shutdown hooks and add bootstrap unit test --- src/main.spec.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 13 ++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/main.spec.ts diff --git a/src/main.spec.ts b/src/main.spec.ts new file mode 100644 index 00000000..7e235998 --- /dev/null +++ b/src/main.spec.ts @@ -0,0 +1,42 @@ +import { bootstrap } from './main'; + +jest.mock('@nestjs/core', () => ({ + NestFactory: { + create: jest.fn(), + }, +})); + +jest.mock('./bootstrap', () => ({ + configureApp: jest.fn(), +})); + +jest.mock('./app.module', () => ({ + AppModule: Symbol('AppModule'), +})); + +const mockApp = { + enableShutdownHooks: jest.fn(), + listen: jest.fn().mockResolvedValue(undefined), +}; + +describe('bootstrap', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('enables shutdown hooks and starts the application', async () => { + const { NestFactory } = await import('@nestjs/core'); + const { AppModule } = await import('./app.module'); + // eslint-disable-next-line @typescript-eslint/unbound-method + const createMock = NestFactory.create as jest.Mock; + createMock.mockResolvedValue(mockApp); + + await bootstrap(); + + expect(createMock).toHaveBeenCalledWith(AppModule, { + bufferLogs: true, + }); + expect(mockApp.enableShutdownHooks).toHaveBeenCalled(); + expect(mockApp.listen).toHaveBeenCalledWith(process.env.PORT ?? 3000); + }); +}); diff --git a/src/main.ts b/src/main.ts index c515fd6b..44d3f8b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,17 @@ import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; import { configureApp } from './bootstrap'; -async function bootstrap() { +export async function bootstrap() { + const { AppModule } = await import('./app.module'); const app = await NestFactory.create(AppModule, { bufferLogs: true }); configureApp(app); + app.enableShutdownHooks(); await app.listen(process.env.PORT ?? 3000); } -bootstrap(); + +if (require.main === module) { + void bootstrap().catch((error) => { + console.error(error); + process.exit(1); + }); +} From feac3c78fcc377cf2eccc88189f03e7a7da7e5df Mon Sep 17 00:00:00 2001 From: Abdulrazaq Isa Babi Date: Fri, 29 May 2026 23:20:28 +0000 Subject: [PATCH 2/2] feat(audit): add scheduled audit log retention job and tests --- src/app.module.ts | 2 + src/audit/audit.module.ts | 6 ++- .../services/audit-retention.service.spec.ts | 42 +++++++++++++++++++ src/audit/services/audit-retention.service.ts | 39 +++++++++++++++++ .../services/audit-trail.service.spec.ts | 37 ++++++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/audit/services/audit-retention.service.spec.ts create mode 100644 src/audit/services/audit-retention.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index a0ab46ac..353fb6f5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,6 @@ import { Module, Logger } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller'; @@ -222,6 +223,7 @@ async function createThrottlerStorage(configService: ConfigService): Promise { + let service: AuditRetentionService; + let auditTrailService: jest.Mocked; + let configService: jest.Mocked; + + beforeEach(() => { + auditTrailService = { + deleteOldLogs: jest.fn(), + } as unknown as jest.Mocked; + + configService = { + get: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('should use configured retention days and purge old audit logs', async () => { + (configService.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'AUDIT_LOG_RETENTION_DAYS') return '30'; + return undefined; + }); + auditTrailService.deleteOldLogs.mockResolvedValue(8); + + service = new AuditRetentionService(auditTrailService, configService); + + await expect(service.purgeOldAuditLogs()).resolves.toBe(8); + expect(auditTrailService.deleteOldLogs).toHaveBeenCalledWith(30); + }); + + it('should default to 365 days when configuration is missing or invalid', async () => { + (configService.get as jest.Mock).mockReturnValue(undefined); + auditTrailService.deleteOldLogs.mockResolvedValue(0); + + service = new AuditRetentionService(auditTrailService, configService); + + await expect(service.purgeOldAuditLogs()).resolves.toBe(0); + expect(auditTrailService.deleteOldLogs).toHaveBeenCalledWith(365); + }); +}); diff --git a/src/audit/services/audit-retention.service.ts b/src/audit/services/audit-retention.service.ts new file mode 100644 index 00000000..66355f29 --- /dev/null +++ b/src/audit/services/audit-retention.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { AuditTrailService } from './audit-trail.service'; + +@Injectable() +export class AuditRetentionService { + private readonly logger = new Logger(AuditRetentionService.name); + private readonly daysToKeep: number; + + constructor( + private readonly auditTrailService: AuditTrailService, + private readonly configService: ConfigService, + ) { + this.daysToKeep = this.resolveRetentionDays(); + } + + @Cron(process.env.AUDIT_LOG_RETENTION_CRON || CronExpression.EVERY_DAY_AT_MIDNIGHT, { + name: 'audit-log-retention', + timeZone: 'UTC', + }) + async purgeOldAuditLogs(): Promise { + const deletedCount = await this.auditTrailService.deleteOldLogs( + this.daysToKeep, + ); + + this.logger.log( + `Audit retention job removed ${deletedCount} records older than ${this.daysToKeep} days`, + ); + + return deletedCount; + } + + private resolveRetentionDays(): number { + const rawDays = this.configService.get('AUDIT_LOG_RETENTION_DAYS'); + const parsedDays = parseInt(rawDays ?? '', 10); + return Number.isNaN(parsedDays) || parsedDays <= 0 ? 365 : parsedDays; + } +} diff --git a/src/audit/services/audit-trail.service.spec.ts b/src/audit/services/audit-trail.service.spec.ts index b0680b81..3dc61170 100644 --- a/src/audit/services/audit-trail.service.spec.ts +++ b/src/audit/services/audit-trail.service.spec.ts @@ -284,6 +284,43 @@ describe('AuditTrailService - IP Security and Masking', () => { }); }); + describe('deleteOldLogs', () => { + it('should delete audit logs older than the configured cutoff date', async () => { + const mockQueryBuilder = { + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 4 }), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const deleted = await service.deleteOldLogs(90); + + expect(repository.createQueryBuilder).toHaveBeenCalledWith('audit'); + expect(mockQueryBuilder.delete).toHaveBeenCalled(); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'audit.createdAt < :cutoff', + expect.objectContaining({ cutoff: expect.any(Date) }), + ); + expect(mockQueryBuilder.execute).toHaveBeenCalled(); + expect(deleted).toBe(4); + }); + + it('should return zero when no old audit logs are deleted', async () => { + const mockQueryBuilder = { + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + } as any; + + (repository.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder); + + const deleted = await service.deleteOldLogs(30); + + expect(deleted).toBe(0); + }); + }); + describe('maskIp utility', () => { it('should handle undefined and empty values', () => { expect(maskIp(undefined)).toBeUndefined();