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
141 changes: 141 additions & 0 deletions src/audit-log/audit-log.entity.http-method.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AuditLog>;

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>(AuditLoggerService);
repository = module.get<Repository<AuditLog>>(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' }));
});
});
2 changes: 1 addition & 1 deletion src/audit-log/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
4 changes: 2 additions & 2 deletions src/audit-log/audit-log.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/audit-log/interfaces/audit-log.interfaces.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,7 +21,7 @@ export interface IAuditLogEntry {
sessionId?: string;
requestId?: string;
apiEndpoint?: string;
httpMethod?: string;
httpMethod?: HttpMethod;
statusCode?: number;
responseTimeMs?: number;
tenantId?: string;
Expand All @@ -44,7 +44,7 @@ export interface IAuditLogSearchFilters {
startDate?: Date;
endDate?: Date;
apiEndpoint?: string;
httpMethod?: string;
httpMethod?: HttpMethod;
statusCode?: number;
}

Expand Down
6 changes: 3 additions & 3 deletions src/audit-log/services/audit-logger.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/audit/audit-logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion src/migrations/1762000000000-create-audit-log-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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"');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void> {
// Revert the column back to varchar.
await queryRunner.query(`
ALTER TABLE "audit_logs"
ALTER COLUMN "http_method"
TYPE varchar
USING "http_method"::text
`);

await queryRunner.query('DROP TYPE IF EXISTS "audit_logs_http_method_enum"');
}
}
Loading