From 51afdc7ee2d9aa6d6a6b13601e3b972893b967b8 Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Thu, 20 Aug 2026 16:10:31 +0100 Subject: [PATCH 1/2] audit_logs: entity/migration disagree on http_method type, version, and timestamp audit_logs: entity/migration disagree on http_method type, version, and timestamp --- .../audit-log.entity.http-method.spec.ts | 141 ++++++++++++++++++ src/audit-log/audit-log.entity.ts | 2 +- src/audit-log/audit-log.service.ts | 4 +- .../interfaces/audit-log.interfaces.ts | 6 +- .../services/audit-logger.service.ts | 6 +- .../audit/audit-logger.middleware.ts | 3 +- .../1762000000000-create-audit-log-table.ts | 15 +- ...0-convert-audit-log-http-method-to-enum.ts | 45 ++++++ 8 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 src/audit-log/audit-log.entity.http-method.spec.ts create mode 100644 src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts diff --git a/src/audit-log/audit-log.entity.http-method.spec.ts b/src/audit-log/audit-log.entity.http-method.spec.ts new file mode 100644 index 00000000..f1a4027f --- /dev/null +++ b/src/audit-log/audit-log.entity.http-method.spec.ts @@ -0,0 +1,141 @@ +import 'reflect-metadata'; +import { HttpMethod, AuditLog } from './audit-log.entity'; + +// ── HttpMethod enum contract ────────────────────────────────────────────────── + +describe('HttpMethod enum', () => { + it('should contain exactly the five standard HTTP verbs', () => { + const members = Object.values(HttpMethod); + expect(members).toEqual(expect.arrayContaining(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])); + expect(members).toHaveLength(5); + }); + + it('should have uppercase string values', () => { + for (const value of Object.values(HttpMethod)) { + expect(value).toMatch(/^[A-Z]+$/); + } + }); + + it.each(Object.values(HttpMethod))('should accept "%s" as a valid enum value', (verb) => { + // Verify each member is assignable to the enum type + const method: HttpMethod = verb; + expect(method).toBe(verb); + }); + + it('should not contain any whitespace or trailing characters', () => { + for (const value of Object.values(HttpMethod)) { + expect(value).toBe(value.trim()); + expect(value).not.toMatch(/\s/); + } + }); +}); + +// ── AuditLog entity column metadata ─────────────────────────────────────────── + +describe('AuditLog entity – http_method column', () => { + it('should declare httpMethod as an enum column with the HttpMethod enum', () => { + // Retrieve TypeORM column metadata via the decorator stack + const columns = (Reflect as any).getMetadata('design:type', AuditLog.prototype, 'httpMethod'); + + // The reflected type is Function (TypeScript enum compiled to object) + expect(columns).toBeDefined(); + }); + + it('should map http_method to the HttpMethod enum values in the entity', () => { + // Verify that assigning a valid HttpMethod works and invalid ones are excluded + const validMethods: HttpMethod[] = [ + HttpMethod.GET, + HttpMethod.POST, + HttpMethod.PUT, + HttpMethod.DELETE, + HttpMethod.PATCH, + ]; + + for (const method of validMethods) { + const log = new AuditLog(); + log.httpMethod = method; + expect(log.httpMethod).toBe(method); + } + }); + + it('should allow null for httpMethod (column is nullable)', () => { + const log = new AuditLog(); + log.httpMethod = null; + expect(log.httpMethod).toBeNull(); + }); +}); + +// ── AuditLoggerService – HttpMethod enforcement ─────────────────────────────── + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLoggerService } from './services/audit-logger.service'; +import { AuditAction, AuditSeverity, AuditCategory } from './enums/audit-action.enum'; +import { ConfigService } from '@nestjs/config'; + +describe('AuditLoggerService – HttpMethod enforcement', () => { + let service: AuditLoggerService; + let repository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditLoggerService, + { + provide: getRepositoryToken(AuditLog), + useValue: { + create: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(365), + }, + }, + ], + }).compile(); + + service = module.get(AuditLoggerService); + repository = module.get>(getRepositoryToken(AuditLog)); + }); + + it.each(Object.values(HttpMethod))( + 'should pass valid HttpMethod "%s" through to repository.create', + async (verb) => { + const mockLog = { id: 'log-1', httpMethod: verb, timestamp: new Date() }; + jest.spyOn(repository, 'create').mockReturnValue(mockLog as AuditLog); + jest.spyOn(repository, 'save').mockResolvedValue(mockLog as AuditLog); + + await service.logApiAccess( + 'user-1', + 'user@example.com', + '/api/test', + verb, + 200, + 50, + '127.0.0.1', + 'test-agent', + ); + + expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ httpMethod: verb })); + }, + ); + + it('should pass the middleware-reported httpMethod through to the repository', async () => { + // Simulates what audit-logger.middleware.ts does: req.method is passed as-is + const mockLog = { id: 'log-1', httpMethod: 'POST', timestamp: new Date() }; + jest.spyOn(repository, 'create').mockReturnValue(mockLog as AuditLog); + jest.spyOn(repository, 'save').mockResolvedValue(mockLog as AuditLog); + + await service.log({ + action: AuditAction.API_CALLED, + category: AuditCategory.DATA_ACCESS, + httpMethod: 'POST' as HttpMethod, + }); + + expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ httpMethod: 'POST' })); + }); +}); diff --git a/src/audit-log/audit-log.entity.ts b/src/audit-log/audit-log.entity.ts index fb3a51a7..817f7151 100644 --- a/src/audit-log/audit-log.entity.ts +++ b/src/audit-log/audit-log.entity.ts @@ -103,7 +103,7 @@ export class AuditLog { apiEndpoint: string | null; /** Constrained to known HTTP verbs — free strings invite silent typos. */ - @Column({ name: 'http_method', nullable: true }) + @Column({ name: 'http_method', type: 'enum', enum: HttpMethod, nullable: true }) httpMethod: HttpMethod | null; @Column({ name: 'status_code', nullable: true }) diff --git a/src/audit-log/audit-log.service.ts b/src/audit-log/audit-log.service.ts index a19562bf..672f9398 100644 --- a/src/audit-log/audit-log.service.ts +++ b/src/audit-log/audit-log.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { AuditAction, AuditSeverity } from './enums/audit-action.enum'; -import { AuditLog } from './audit-log.entity'; +import { AuditLog, HttpMethod } from './audit-log.entity'; import { AuditLoggerService } from './services/audit-logger.service'; import { AuditQueryService } from './services/audit-query.service'; import { AuditReportingService } from './services/audit-reporting.service'; @@ -53,7 +53,7 @@ export interface LogApiAccessOptions { userId: string | null; userEmail: string | null; apiEndpoint: string; - httpMethod: string; + httpMethod: HttpMethod; statusCode: number; responseTimeMs: number; ipAddress: string; diff --git a/src/audit-log/interfaces/audit-log.interfaces.ts b/src/audit-log/interfaces/audit-log.interfaces.ts index 3ac5b12e..54e04df3 100644 --- a/src/audit-log/interfaces/audit-log.interfaces.ts +++ b/src/audit-log/interfaces/audit-log.interfaces.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditSeverity, AuditCategory } from '../enums/audit-action.enum'; -import { AuditLog } from '../audit-log.entity'; +import { AuditLog, HttpMethod } from '../audit-log.entity'; /** * Audit log entry data structure for logging operations @@ -21,7 +21,7 @@ export interface IAuditLogEntry { sessionId?: string; requestId?: string; apiEndpoint?: string; - httpMethod?: string; + httpMethod?: HttpMethod; statusCode?: number; responseTimeMs?: number; tenantId?: string; @@ -44,7 +44,7 @@ export interface IAuditLogSearchFilters { startDate?: Date; endDate?: Date; apiEndpoint?: string; - httpMethod?: string; + httpMethod?: HttpMethod; statusCode?: number; } diff --git a/src/audit-log/services/audit-logger.service.ts b/src/audit-log/services/audit-logger.service.ts index 99b433e4..161d4177 100644 --- a/src/audit-log/services/audit-logger.service.ts +++ b/src/audit-log/services/audit-logger.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { AuditLog } from '../audit-log.entity'; +import { AuditLog, HttpMethod } from '../audit-log.entity'; import { AuditAction, AuditSeverity, AuditCategory } from '../enums/audit-action.enum'; import { ConfigService } from '@nestjs/config'; import { sanitizePii } from '../../common/utils/pii-sanitizer.utils'; @@ -42,7 +42,7 @@ export class AuditLoggerService { ...entry, severity: (entry.severity || AuditSeverity.INFO) as any, retentionUntil, - httpMethod: entry.httpMethod as any, + httpMethod: entry.httpMethod, }); try { @@ -118,7 +118,7 @@ export class AuditLoggerService { userId: string | null, userEmail: string | null, apiEndpoint: string, - httpMethod: string, + httpMethod: HttpMethod, statusCode: number, responseTimeMs: number, ipAddress: string, diff --git a/src/middleware/audit/audit-logger.middleware.ts b/src/middleware/audit/audit-logger.middleware.ts index ec11d942..f0dad125 100644 --- a/src/middleware/audit/audit-logger.middleware.ts +++ b/src/middleware/audit/audit-logger.middleware.ts @@ -1,6 +1,7 @@ import { Logger } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { AuditLogService } from '../../audit-log/audit-log.service'; +import { HttpMethod } from '../../audit-log/audit-log.entity'; import { AuditSeverity } from '../../audit-log/enums/audit-action.enum'; import { resolveUserAction } from './user-action-tracker'; @@ -51,7 +52,7 @@ export function createAuditLoggerMiddleware(auditLogService: AuditLogService) { severity, description: userAction.description, apiEndpoint: endpoint, - httpMethod: req.method, + httpMethod: req.method as HttpMethod, statusCode, responseTimeMs, ipAddress: req.ip, diff --git a/src/migrations/1762000000000-create-audit-log-table.ts b/src/migrations/1762000000000-create-audit-log-table.ts index c3cd8ffa..4c826b72 100644 --- a/src/migrations/1762000000000-create-audit-log-table.ts +++ b/src/migrations/1762000000000-create-audit-log-table.ts @@ -76,6 +76,14 @@ export class CreateAuditLogTable1762000000000 implements MigrationInterface { EXCEPTION WHEN duplicate_object THEN NULL; END $$; `); + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "audit_logs_http_method_enum" AS ENUM ( + 'GET', 'POST', 'PUT', 'DELETE', 'PATCH' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + await queryRunner.createTable( new Table({ name: 'audit_logs', @@ -117,7 +125,11 @@ export class CreateAuditLogTable1762000000000 implements MigrationInterface { { name: 'session_id', type: 'varchar', isNullable: true }, { name: 'request_id', type: 'varchar', isNullable: true }, { name: 'api_endpoint', type: 'varchar', isNullable: true }, - { name: 'http_method', type: 'varchar', isNullable: true }, + { + name: 'http_method', + type: 'audit_logs_http_method_enum', + isNullable: true, + }, { name: 'status_code', type: 'int', isNullable: true }, { name: 'response_time_ms', type: 'int', isNullable: true }, { name: 'tenant_id', type: 'varchar', isNullable: true }, @@ -171,6 +183,7 @@ export class CreateAuditLogTable1762000000000 implements MigrationInterface { await queryRunner.query('DROP TRIGGER IF EXISTS trg_audit_logs_block_update ON audit_logs;'); await queryRunner.query('DROP FUNCTION IF EXISTS audit_logs_block_mutation();'); await queryRunner.dropTable('audit_logs', true); + await queryRunner.query('DROP TYPE IF EXISTS "audit_logs_http_method_enum"'); await queryRunner.query('DROP TYPE IF EXISTS "audit_logs_severity_enum"'); await queryRunner.query('DROP TYPE IF EXISTS "audit_logs_category_enum"'); await queryRunner.query('DROP TYPE IF EXISTS "audit_logs_action_enum"'); diff --git a/src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts b/src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts new file mode 100644 index 00000000..8c1c2e62 --- /dev/null +++ b/src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Convert `audit_logs.http_method` from varchar to a PostgreSQL enum, + * aligning the database schema with the AuditLog entity which declares + * `type: 'enum', enum: HttpMethod`. + * + * See issue #1203. + */ +export class ConvertAuditLogHttpMethodToEnum1797000000000 implements MigrationInterface { + name = 'ConvertAuditLogHttpMethodToEnum1797000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Create the enum type (idempotent). + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "audit_logs_http_method_enum" AS ENUM ( + 'GET', 'POST', 'PUT', 'DELETE', 'PATCH' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + + // 2. Cast the existing varchar column to the new enum type. + // Any existing values that don't match a valid enum member will cause + // the migration to fail, which is the desired safety check. + await queryRunner.query(` + ALTER TABLE "audit_logs" + ALTER COLUMN "http_method" + TYPE "audit_logs_http_method_enum" + USING "http_method"::"audit_logs_http_method_enum" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Revert the column back to varchar. + await queryRunner.query(` + ALTER TABLE "audit_logs" + ALTER COLUMN "http_method" + TYPE "character varying" + USING "http_method"::"text" + `); + + await queryRunner.query('DROP TYPE IF EXISTS "audit_logs_http_method_enum"'); + } +} From 2e95ee97b87c1d548182f33c04b807b22de24270 Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Thu, 20 Aug 2026 16:29:05 +0100 Subject: [PATCH 2/2] fixes fixes --- .../1797000000000-convert-audit-log-http-method-to-enum.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts b/src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts index 8c1c2e62..259ddbbd 100644 --- a/src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts +++ b/src/migrations/1797000000000-convert-audit-log-http-method-to-enum.ts @@ -36,8 +36,8 @@ export class ConvertAuditLogHttpMethodToEnum1797000000000 implements MigrationIn await queryRunner.query(` ALTER TABLE "audit_logs" ALTER COLUMN "http_method" - TYPE "character varying" - USING "http_method"::"text" + TYPE varchar + USING "http_method"::text `); await queryRunner.query('DROP TYPE IF EXISTS "audit_logs_http_method_enum"');