Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,12 +49,12 @@

async increment(
key: string,
ttl: number,

Check failure on line 52 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Async method 'increment' has no 'await' expression
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<{ totalHits: number; timeToExpire: number; isBlocked: boolean; timeToBlockExpire: number }> {
const now = Date.now();

Check failure on line 57 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

'throttlerName' is defined but never used
const record = this.storage.get(key);

if (!record) {
Expand Down Expand Up @@ -127,7 +128,7 @@
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,

Check failure on line 131 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
): Promise<{ totalHits: number; timeToExpire: number; isBlocked: boolean; timeToBlockExpire: number }> {
const blockKey = `${key}:blocked`;
const [blocked, blockTimeToExpire] = await Promise.all([
Expand All @@ -136,7 +137,7 @@
]);

if (blocked) {
const timeToExpire = await this.redis.pttl(key);

Check failure on line 140 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

'throttlerName' is defined but never used
return {
totalHits: await this.redis.get(key).then((value: string | null) => Number(value) || limit + 1),
timeToExpire: timeToExpire > 0 ? timeToExpire : ttl,
Expand All @@ -144,9 +145,9 @@
timeToBlockExpire: blockTimeToExpire > 0 ? blockTimeToExpire : 0,
};
}

Check failure on line 148 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe array destructuring of a tuple element with an `any` value

Check failure on line 148 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe array destructuring of a tuple element with an `any` value
const [totalHits, existingTtl] = await Promise.all([

Check failure on line 149 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .exists on an `any` value

Check failure on line 149 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe call of an `any` typed value
this.redis.incr(key),

Check failure on line 150 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe call of an `any` typed value
this.redis.pttl(key),
]);

Expand Down Expand Up @@ -222,6 +223,7 @@
load: [blockchainConfig, throttlerConfig, sybilConfig],
envFilePath: ['.env.local', '.env'],
}),
ScheduleModule.forRoot(),
TypeOrmModule.forRoot({
type: 'sqlite',
database: 'database.sqlite',
Expand Down
6 changes: 4 additions & 2 deletions src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Module, Global } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { AuditLog } from './entities/audit-log.entity';
import { AuditTrailService } from './services/audit-trail.service';
import { AuditRetentionService } from './services/audit-retention.service';
import { AuditController } from './controllers/audit-log.controller';
import { AuditLoggingInterceptor } from './interceptors/audit-logging.interceptor';

@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
providers: [AuditTrailService, AuditLoggingInterceptor],
imports: [TypeOrmModule.forFeature([AuditLog]), ScheduleModule],
providers: [AuditTrailService, AuditLoggingInterceptor, AuditRetentionService],
controllers: [AuditController],
exports: [AuditTrailService, AuditLoggingInterceptor],
})
Expand Down
42 changes: 42 additions & 0 deletions src/audit/services/audit-retention.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AuditRetentionService } from './audit-retention.service';
import { AuditTrailService } from './audit-trail.service';
import { ConfigService } from '@nestjs/config';

describe('AuditRetentionService', () => {
let service: AuditRetentionService;
let auditTrailService: jest.Mocked<AuditTrailService>;
let configService: jest.Mocked<ConfigService>;

beforeEach(() => {
auditTrailService = {
deleteOldLogs: jest.fn(),
} as unknown as jest.Mocked<AuditTrailService>;

configService = {
get: jest.fn(),
} as unknown as jest.Mocked<ConfigService>;
});

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);
});
});
39 changes: 39 additions & 0 deletions src/audit/services/audit-retention.service.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<string>('AUDIT_LOG_RETENTION_DAYS');
const parsedDays = parseInt(rawDays ?? '', 10);
return Number.isNaN(parsedDays) || parsedDays <= 0 ? 365 : parsedDays;
}
}
37 changes: 37 additions & 0 deletions src/audit/services/audit-trail.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
42 changes: 42 additions & 0 deletions src/main.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 10 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading