From 83e9d3429bf8ba3d78e747a161bbe6a958912d02 Mon Sep 17 00:00:00 2001 From: Nemenwa Date: Sat, 18 Jul 2026 14:52:17 +0100 Subject: [PATCH 1/7] fix: resolve duplicate properties and syntax errors in user entity and admin test spec --- src/admin/admin.service.spec.ts | 2 -- src/users/entities/user.entity.ts | 5 ----- 2 files changed, 7 deletions(-) diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index 21b7f5b..7622426 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -19,8 +19,6 @@ describe('AdminService', () => { findAndCount: jest.fn(), findOne: jest.fn(), save: jest.fn(), - findOne: jest.fn(), - save: jest.fn(), count: jest.fn(), }; diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index cec9975..4beb9d0 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -12,10 +12,6 @@ export class User { @Column({ unique: true }) email!: string; - // Keep both temporarily if both branches are already using them - @Column() - passwordHash!: string; - @Column() passwordHash!: string; @@ -31,4 +27,3 @@ export class User { @UpdateDateColumn() updatedAt!: Date; } -} From d1cf6bba7cd5b8027b65167c7e70f58fe597f7fb Mon Sep 17 00:00:00 2001 From: Nemenwa Date: Sat, 18 Jul 2026 14:52:33 +0100 Subject: [PATCH 2/7] chore: clear platform constraints from package-lock.json for npm install --- package-lock.json | 128 +++++++++------------------------------------- 1 file changed, 25 insertions(+), 103 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5df0ba4..6cda207 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1777,19 +1777,13 @@ "version": "30.4.0", "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", - "license": "MIT", - "os": [ - "android" - ] + "license": "MIT" }, "node_modules/@jest/environment": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", - "license": "MIT", - "os": [ - "darwin" - ] + "license": "MIT" }, "node_modules/@jest/expect": { "version": "29.7.0", @@ -2211,19 +2205,13 @@ "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", - "license": "MIT", - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@jest/get-type": { "version": "30.1.0", "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "license": "MIT", - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@jest/globals": { "version": "30.4.1", @@ -3984,10 +3972,7 @@ "arm" ], "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-android-arm64": { "version": "1.12.2", @@ -3997,10 +3982,7 @@ "arm64" ], "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.12.2", @@ -4010,10 +3992,7 @@ "arm64" ], "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-darwin-x64": { "version": "1.12.2", @@ -4023,10 +4002,7 @@ "x64" ], "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-freebsd-x64": { "version": "1.12.2", @@ -4036,10 +4012,7 @@ "x64" ], "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { "version": "1.12.2", @@ -4049,10 +4022,7 @@ "arm" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { "version": "1.12.2", @@ -4062,10 +4032,7 @@ "arm" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { "version": "1.12.2", @@ -4075,10 +4042,7 @@ "arm64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { "version": "1.12.2", @@ -4088,10 +4052,7 @@ "arm64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { "version": "1.12.2", @@ -4101,10 +4062,7 @@ "loong64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-loong64-musl": { "version": "1.12.2", @@ -4114,10 +4072,7 @@ "loong64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { "version": "1.12.2", @@ -4127,10 +4082,7 @@ "ppc64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { "version": "1.12.2", @@ -4140,10 +4092,7 @@ "riscv64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { "version": "1.12.2", @@ -4153,10 +4102,7 @@ "riscv64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { "version": "1.12.2", @@ -4166,10 +4112,7 @@ "s390x" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.12.2", @@ -4179,10 +4122,7 @@ "x64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { "version": "1.12.2", @@ -4192,10 +4132,7 @@ "x64" ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-openharmony-arm64": { "version": "1.12.2", @@ -4205,10 +4142,7 @@ "arm64" ], "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { "version": "1.12.2", @@ -4236,10 +4170,7 @@ "arm64" ], "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { "version": "1.12.2", @@ -4249,10 +4180,7 @@ "ia32" ], "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.12.2", @@ -4262,10 +4190,7 @@ "x64" ], "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", @@ -6599,9 +6524,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } From 276ba767548d26aeb2e54bf4a8861cf487aa083e Mon Sep 17 00:00:00 2001 From: Nemenwa Date: Sat, 18 Jul 2026 14:53:46 +0100 Subject: [PATCH 3/7] feat(audit): redefine AuditLog entity schema --- src/audit/entities/audit-log.entity.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/audit/entities/audit-log.entity.ts b/src/audit/entities/audit-log.entity.ts index b6be046..a6b6f61 100644 --- a/src/audit/entities/audit-log.entity.ts +++ b/src/audit/entities/audit-log.entity.ts @@ -6,17 +6,20 @@ export class AuditLog { id!: string; @Column() - action!: string; + actorId!: string; @Column() - performedBy!: string; + action!: string; + + @Column({ nullable: true }) + targetEntity!: string; @Column({ nullable: true }) targetId!: string; @Column({ type: 'json', nullable: true }) - metadata!: object; + payload!: object; - @CreateDateColumn() - createdAt!: Date; + @CreateDateColumn({ name: 'timestamp' }) + timestamp!: Date; } From 1de807c771bd81a7fd3c54bc2aca73966c9b54d0 Mon Sep 17 00:00:00 2001 From: Nemenwa Date: Sat, 18 Jul 2026 14:55:05 +0100 Subject: [PATCH 4/7] feat(audit): implement AuditService with data sanitization and log retrieval --- src/audit/audit.service.ts | 81 +++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts index 0693ed0..a4a4c36 100644 --- a/src/audit/audit.service.ts +++ b/src/audit/audit.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Between, LessThanOrEqual, MoreThanOrEqual, FindOptionsWhere, Repository } from 'typeorm'; import { AuditLog } from './entities/audit-log.entity'; @Injectable() @@ -10,13 +10,84 @@ export class AuditService { private readonly auditLogRepository: Repository, ) {} - async log(action: string, performedBy: string, targetId?: string, metadata?: object): Promise { + scrubPayload(val: any): any { + if (val === null || val === undefined) { + return val; + } + if (Array.isArray(val)) { + return val.map((item) => this.scrubPayload(item)); + } + if (typeof val === 'object' && (val.constructor === Object || !val.constructor)) { + const scrubbed: any = {}; + const sensitiveKeys = ['password', 'token', 'passwordhash', 'accesstoken', 'jwt', 'secret', 'authorization', 'signature']; + for (const key of Object.keys(val)) { + if (sensitiveKeys.includes(key.toLowerCase())) { + scrubbed[key] = '[SCRUBBED]'; + } else { + scrubbed[key] = this.scrubPayload(val[key]); + } + } + return scrubbed; + } + return val; + } + + async log( + action: string, + actorId: string, + targetId?: string, + targetEntity?: string, + payload?: object, + ): Promise { + const scrubbedPayload = payload ? this.scrubPayload(payload) : undefined; const log = this.auditLogRepository.create({ action, - performedBy, + actorId, targetId, - metadata, + targetEntity, + payload: scrubbedPayload, + }); + return this.auditLogRepository.save(log); + } + + async findAll(query: { + actor?: string; + action?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; + }) { + const page = Number(query.page || 1); + const limit = Number(query.limit || 20); + const where: FindOptionsWhere = {}; + + if (query.actor) { + where.actorId = query.actor; + } + if (query.action) { + where.action = query.action; + } + if (query.startDate && query.endDate) { + where.timestamp = Between(new Date(query.startDate), new Date(query.endDate)); + } else if (query.startDate) { + where.timestamp = MoreThanOrEqual(new Date(query.startDate)); + } else if (query.endDate) { + where.timestamp = LessThanOrEqual(new Date(query.endDate)); + } + + const [data, total] = await this.auditLogRepository.findAndCount({ + where, + skip: (page - 1) * limit, + take: limit, + order: { timestamp: 'DESC' }, }); - await this.auditLogRepository.save(log); + + return { + data, + total, + page, + limit, + }; } } From 009424ba5ad7d7e2b3d802918748930395753abb Mon Sep 17 00:00:00 2001 From: Nemenwa Date: Sat, 18 Jul 2026 14:58:29 +0100 Subject: [PATCH 5/7] feat(audit): create AuditInterceptor and custom decorators for auto logging --- src/audit/audit.interceptor.ts | 79 +++++++++++++++++++++++++ src/audit/decorators/audit.decorator.ts | 7 +++ 2 files changed, 86 insertions(+) create mode 100644 src/audit/audit.interceptor.ts create mode 100644 src/audit/decorators/audit.decorator.ts diff --git a/src/audit/audit.interceptor.ts b/src/audit/audit.interceptor.ts new file mode 100644 index 0000000..c788c5e --- /dev/null +++ b/src/audit/audit.interceptor.ts @@ -0,0 +1,79 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuditService } from './audit.service'; +import { AUDIT_ACTION_KEY, AUDIT_TARGET_ENTITY_KEY } from './decorators/audit.decorator'; + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor( + private readonly auditService: AuditService, + private readonly reflector: Reflector, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + + const url = request.url || ''; + const isAdminRoute = url.startsWith('/admin') || url.startsWith('admin'); + const isMutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method); + + if (!isAdminRoute || !isMutating) { + return next.handle(); + } + + return next.handle().pipe( + tap({ + next: (data) => { + const handler = context.getHandler(); + const actorId = request.user?.id || 'anonymous'; + + let action = this.reflector.get(AUDIT_ACTION_KEY, handler); + if (!action) { + const methodName = handler.name; + action = methodName.replace(/([A-Z])/g, '_$1').toUpperCase(); + } + + let targetEntity = this.reflector.get(AUDIT_TARGET_ENTITY_KEY, handler); + if (!targetEntity) { + if (url.includes('/users')) { + targetEntity = 'User'; + } else if (url.includes('/submissions') || url.includes('/puzzles') || url.includes('/calibration')) { + targetEntity = 'Puzzle'; + } else if (url.includes('/rewards')) { + targetEntity = 'Reward'; + } else if (url.includes('/nft')) { + targetEntity = 'Nft'; + } else if (url.includes('/sessions')) { + targetEntity = 'Session'; + } + } + + let targetId = request.params?.id || request.params?.puzzleId || request.params?.userId || request.params?.targetId; + if (!targetId && request.body && request.body.id) { + targetId = request.body.id; + } + if (!targetId && data && typeof data === 'object' && data.id) { + targetId = data.id; + } + + const payload = { + body: request.body, + query: request.query, + params: request.params, + }; + + this.auditService.log(action, actorId, targetId, targetEntity, payload).catch((err) => { + console.error('Failed to write audit log:', err); + }); + }, + }), + ); + } +} diff --git a/src/audit/decorators/audit.decorator.ts b/src/audit/decorators/audit.decorator.ts new file mode 100644 index 0000000..18fb18f --- /dev/null +++ b/src/audit/decorators/audit.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; + +export const AUDIT_ACTION_KEY = 'audit_action'; +export const AUDIT_TARGET_ENTITY_KEY = 'audit_target_entity'; + +export const AuditAction = (action: string) => SetMetadata(AUDIT_ACTION_KEY, action); +export const AuditTargetEntity = (entity: string) => SetMetadata(AUDIT_TARGET_ENTITY_KEY, entity); From e07fea7cfac0019e6394fa04749c8e74b45994f8 Mon Sep 17 00:00:00 2001 From: Nemenwa Date: Sun, 19 Jul 2026 19:19:08 +0100 Subject: [PATCH 6/7] feat(audit): implement AuditController and configure AuditModule with global interceptor --- src/audit/audit.controller.ts | 32 ++++++++++++++++++++++++++++++++ src/audit/audit.module.ts | 12 +++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/audit/audit.controller.ts diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts new file mode 100644 index 0000000..d0afb89 --- /dev/null +++ b/src/audit/audit.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AuditService } from './audit.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { Role } from '../common/enums/role.enum'; + +@Controller('admin/audit') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.ADMIN) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + async getAuditLogs( + @Query('actor') actor?: string, + @Query('action') action?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.auditService.findAll({ + actor, + action, + startDate, + endDate, + page: page ? parseInt(page, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + }); + } +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts index 1bffb53..952ee84 100644 --- a/src/audit/audit.module.ts +++ b/src/audit/audit.module.ts @@ -1,11 +1,21 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { AuditLog } from './entities/audit-log.entity'; import { AuditService } from './audit.service'; +import { AuditController } from './audit.controller'; +import { AuditInterceptor } from './audit.interceptor'; @Module({ imports: [TypeOrmModule.forFeature([AuditLog])], - providers: [AuditService], + controllers: [AuditController], + providers: [ + AuditService, + { + provide: APP_INTERCEPTOR, + useClass: AuditInterceptor, + }, + ], exports: [AuditService], }) export class AuditModule {} From e8c10b0ed9417d19241e44c98db489273b30b9b1 Mon Sep 17 00:00:00 2001 From: Nemenwa Date: Sun, 19 Jul 2026 19:25:20 +0100 Subject: [PATCH 7/7] test(audit): add unit tests for AuditService and AuditInterceptor --- src/audit/audit.interceptor.spec.ts | 168 ++++++++++++++++++++++++++ src/audit/audit.service.spec.ts | 180 ++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 src/audit/audit.interceptor.spec.ts create mode 100644 src/audit/audit.service.spec.ts diff --git a/src/audit/audit.interceptor.spec.ts b/src/audit/audit.interceptor.spec.ts new file mode 100644 index 0000000..54211ec --- /dev/null +++ b/src/audit/audit.interceptor.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { of } from 'rxjs'; +import { AuditInterceptor } from './audit.interceptor'; +import { AuditService } from './audit.service'; +import { AuditController } from './audit.controller'; + +describe('AuditInterceptor', () => { + let interceptor: AuditInterceptor; + let auditService: AuditService; + + const mockAuditService = { + log: jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({}), 50))), + }; + + const mockReflector = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditInterceptor, + { + provide: AuditService, + useValue: mockAuditService, + }, + { + provide: Reflector, + useValue: mockReflector, + }, + ], + }).compile(); + + interceptor = module.get(AuditInterceptor); + auditService = module.get(AuditService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(interceptor).toBeDefined(); + }); + + describe('interception filtering', () => { + it('should bypass non-admin routes', async () => { + const mockRequest = { url: '/public/puzzles', method: 'POST', user: { id: 'admin1' } }; + const context = createMockExecutionContext(mockRequest); + const next: CallHandler = { + handle: () => of({ test: true }), + }; + + await interceptor.intercept(context, next).toPromise(); + + expect(mockAuditService.log).not.toHaveBeenCalled(); + }); + + it('should bypass GET requests on admin routes', async () => { + const mockRequest = { url: '/admin/users', method: 'GET', user: { id: 'admin1' } }; + const context = createMockExecutionContext(mockRequest); + const next: CallHandler = { + handle: () => of({ test: true }), + }; + + await interceptor.intercept(context, next).toPromise(); + + expect(mockAuditService.log).not.toHaveBeenCalled(); + }); + + it('should log mutating requests on admin routes', async () => { + const mockRequest = { + url: '/admin/users/123/ban', + method: 'PATCH', + params: { id: '123' }, + body: { reason: 'spam' }, + user: { id: 'admin1' }, + }; + + const context = createMockExecutionContext(mockRequest, 'banUser'); + const next: CallHandler = { + handle: () => of({ success: true, id: '123' }), + }; + + await interceptor.intercept(context, next).toPromise(); + + expect(mockAuditService.log).toHaveBeenCalledWith( + 'BAN_USER', + 'admin1', + '123', + 'User', + expect.objectContaining({ body: { reason: 'spam' } }) + ); + }); + + it('should log even if user is anonymous', async () => { + const mockRequest = { + url: '/admin/users/123/ban', + method: 'PATCH', + params: { id: '123' }, + user: undefined, + }; + + const context = createMockExecutionContext(mockRequest, 'banUser'); + const next: CallHandler = { + handle: () => of({ success: true }), + }; + + await interceptor.intercept(context, next).toPromise(); + + expect(mockAuditService.log).toHaveBeenCalledWith( + 'BAN_USER', + 'anonymous', + '123', + 'User', + expect.any(Object) + ); + }); + }); + + describe('non-blocking writes', () => { + it('should complete interceptor call immediately without awaiting log write', async () => { + const mockRequest = { + url: '/admin/puzzle/approve', + method: 'POST', + params: { puzzleId: 'p1' }, + user: { id: 'admin1' }, + }; + const context = createMockExecutionContext(mockRequest, 'approvePuzzle'); + const next: CallHandler = { + handle: () => of({ success: true }), + }; + + const startTime = Date.now(); + await interceptor.intercept(context, next).toPromise(); + const endTime = Date.now(); + + expect(endTime - startTime).toBeLessThan(35); + }); + }); + + describe('immutability checking', () => { + it('should only expose GET route mappings on AuditController', () => { + const methods = Object.getOwnPropertyNames(AuditController.prototype); + + for (const method of methods) { + if (method === 'constructor') continue; + const originalMethod = AuditController.prototype[method]; + const requestMethodCode = Reflect.getMetadata('method', originalMethod); + if (requestMethodCode !== undefined) { + expect(requestMethodCode).toBe(0); + } + } + }); + }); + + function createMockExecutionContext(req: any, handlerName = 'testHandler'): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => ({}), + }), + getHandler: () => ({ + name: handlerName, + }), + getClass: () => ({}), + } as unknown as ExecutionContext; + } +}); diff --git a/src/audit/audit.service.spec.ts b/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..bce2ec3 --- /dev/null +++ b/src/audit/audit.service.spec.ts @@ -0,0 +1,180 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { AuditService } from './audit.service'; +import { AuditLog } from './entities/audit-log.entity'; + +describe('AuditService', () => { + let service: AuditService; + + const mockAuditLogRepository = { + create: jest.fn(), + save: jest.fn(), + findAndCount: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditService, + { + provide: getRepositoryToken(AuditLog), + useValue: mockAuditLogRepository, + }, + ], + }).compile(); + + service = module.get(AuditService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('scrubPayload', () => { + it('should deeply scrub sensitive fields', () => { + const input = { + username: 'admin', + password: 'super-secret-password', + nested: { + token: 'jwt-token-here', + publicField: 'hello', + secret: { + deepKey: 'deep-val', + hash: 'hash-here', + }, + }, + }; + + const result = service.scrubPayload(input); + + expect(result.username).toBe('admin'); + expect(result.password).toBe('[SCRUBBED]'); + expect(result.nested.token).toBe('[SCRUBBED]'); + expect(result.nested.publicField).toBe('hello'); + // note: 'secret' is in sensitive list, so it will be replaced by [SCRUBBED] + expect(result.nested.secret).toBe('[SCRUBBED]'); + }); + + it('should ignore null, undefined, or primitive values', () => { + expect(service.scrubPayload(null)).toBeNull(); + expect(service.scrubPayload(undefined)).toBeUndefined(); + expect(service.scrubPayload('primitive')).toBe('primitive'); + }); + + it('should handle array scrubbing', () => { + const arr = [ + { password: '123' }, + { username: 'test' }, + ]; + const result = service.scrubPayload(arr); + expect(result[0].password).toBe('[SCRUBBED]'); + expect(result[1].username).toBe('test'); + }); + }); + + describe('log', () => { + it('should create and save a scrubbed log', async () => { + const mockLog = { id: 'uuid-1', action: 'BAN_USER' }; + mockAuditLogRepository.create.mockReturnValue(mockLog); + mockAuditLogRepository.save.mockResolvedValue(mockLog); + + const payload = { details: 'ban details', password: '123' }; + await service.log('BAN_USER', 'admin-id', 'user-id', 'User', payload); + + expect(mockAuditLogRepository.create).toHaveBeenCalledWith({ + action: 'BAN_USER', + actorId: 'admin-id', + targetId: 'user-id', + targetEntity: 'User', + payload: { details: 'ban details', password: '[SCRUBBED]' }, + }); + expect(mockAuditLogRepository.save).toHaveBeenCalledWith(mockLog); + }); + }); + + describe('findAll', () => { + it('should default pagination structure and query without filters', async () => { + mockAuditLogRepository.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.findAll({}); + + expect(result).toEqual({ + data: [], + total: 0, + page: 1, + limit: 20, + }); + + expect(mockAuditLogRepository.findAndCount).toHaveBeenCalledWith({ + where: {}, + skip: 0, + take: 20, + order: { timestamp: 'DESC' }, + }); + }); + + it('should format filters for actor, action, and date range', async () => { + mockAuditLogRepository.findAndCount.mockResolvedValue([[], 0]); + + const query = { + actor: 'user-1', + action: 'BAN_USER', + startDate: '2026-01-01', + endDate: '2026-01-02', + page: 2, + limit: 10, + }; + + await service.findAll(query); + + expect(mockAuditLogRepository.findAndCount).toHaveBeenCalledWith({ + where: { + actorId: 'user-1', + action: 'BAN_USER', + timestamp: Between(new Date('2026-01-01'), new Date('2026-01-02')), + }, + skip: 10, + take: 10, + order: { timestamp: 'DESC' }, + }); + }); + + it('should handle single date filters', async () => { + mockAuditLogRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.findAll({ startDate: '2026-01-01' }); + expect(mockAuditLogRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + timestamp: MoreThanOrEqual(new Date('2026-01-01')), + }, + }) + ); + + await service.findAll({ endDate: '2026-01-01' }); + expect(mockAuditLogRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + timestamp: LessThanOrEqual(new Date('2026-01-01')), + }, + }) + ); + }); + }); + + describe('immutability', () => { + it('should not contain any update or delete methods in service', () => { + const methods = Object.getOwnPropertyNames(AuditService.prototype); + const mutatingMethods = methods.filter( + (m) => + m.toLowerCase().includes('delete') || + m.toLowerCase().includes('remove') || + m.toLowerCase().includes('update') || + m.toLowerCase().includes('edit') + ); + expect(mutatingMethods.length).toBe(0); + }); + }); +});