From 2a2a969f0438b9b8e2a18a370ebe7c99db9c20e6 Mon Sep 17 00:00:00 2001 From: Habibah371 Date: Mon, 1 Jun 2026 12:50:35 +0100 Subject: [PATCH] Setup disaster recovery Setup disaster recovery --- src/audit-log/audit-log.entity.ts | 61 ++-- src/audit-log/audit-log.service.ts | 292 +++++++++--------- .../cache-invalidation.listener.spec.ts | 114 +++++-- 3 files changed, 266 insertions(+), 201 deletions(-) diff --git a/src/audit-log/audit-log.entity.ts b/src/audit-log/audit-log.entity.ts index b9e890db..dd002a4e 100644 --- a/src/audit-log/audit-log.entity.ts +++ b/src/audit-log/audit-log.entity.ts @@ -4,12 +4,21 @@ import { PrimaryGeneratedColumn, CreateDateColumn, Index, - VersionColumn, } from 'typeorm'; -import { AuditAction, AuditSeverity, AuditCategory } from './enums/audit-action.enum'; +import { AuditAction, AuditSeverity, AuditCategory, HttpMethod } from './enums/audit-action.enum'; /** - * Represents the audit Log entity. + * Immutable audit log record. + * + * Rows are append-only — never updated or soft-deleted. Expiry is handled + * exclusively via `applyRetentionPolicy`, which hard-deletes rows whose + * `retentionUntil` has passed. + * + * Index strategy: + * Composite (column + timestamp) indexes support the most common queries: + * "all events for user X, newest first", "all CRITICAL events this week", etc. + * The `retentionUntil` index supports efficient bulk-delete during retention + * policy runs without a full table scan. */ @Entity('audit_logs') @Index(['userId', 'timestamp']) @@ -17,14 +26,14 @@ import { AuditAction, AuditSeverity, AuditCategory } from './enums/audit-action. @Index(['category', 'timestamp']) @Index(['severity', 'timestamp']) @Index(['entityType', 'entityId']) -@Index(['ipAddress']) +@Index(['ipAddress', 'timestamp']) @Index(['timestamp']) +@Index(['retentionUntil']) // required for efficient retention policy deletes export class AuditLog { @PrimaryGeneratedColumn('uuid') id: string; - @VersionColumn() - version: number; + // ── Actor ────────────────────────────────────────────────────────────────── @Column({ name: 'user_id', nullable: true }) userId: string | null; @@ -32,31 +41,27 @@ export class AuditLog { @Column({ name: 'user_email', nullable: true }) userEmail: string | null; - @Column({ - type: 'enum', - enum: AuditAction, - }) + // ── Event classification ─────────────────────────────────────────────────── + + @Column({ type: 'enum', enum: AuditAction }) action: AuditAction; - @Column({ - type: 'enum', - enum: AuditCategory, - }) + @Column({ type: 'enum', enum: AuditCategory }) category: AuditCategory; - @Column({ - type: 'enum', - enum: AuditSeverity, - default: AuditSeverity.INFO, - }) + @Column({ type: 'enum', enum: AuditSeverity, default: AuditSeverity.INFO }) severity: AuditSeverity; + // ── Target entity ────────────────────────────────────────────────────────── + @Column({ name: 'entity_type', nullable: true }) entityType: string | null; @Column({ name: 'entity_id', nullable: true }) entityId: string | null; + // ── Payload ──────────────────────────────────────────────────────────────── + @Column({ type: 'text', nullable: true }) description: string | null; @@ -69,6 +74,8 @@ export class AuditLog { @Column({ name: 'new_values', type: 'jsonb', nullable: true }) newValues: Record | null; + // ── Request context ──────────────────────────────────────────────────────── + @Column({ name: 'ip_address', nullable: true }) ipAddress: string | null; @@ -84,8 +91,9 @@ export class AuditLog { @Column({ name: 'api_endpoint', nullable: true }) apiEndpoint: string | null; - @Column({ name: 'http_method', nullable: true }) - httpMethod: string | null; + /** Constrained to known HTTP verbs — free strings invite silent typos. */ + @Column({ name: 'http_method', type: 'enum', enum: HttpMethod, nullable: true }) + httpMethod: HttpMethod | null; @Column({ name: 'status_code', nullable: true }) statusCode: number | null; @@ -93,12 +101,21 @@ export class AuditLog { @Column({ name: 'response_time_ms', nullable: true }) responseTimeMs: number | null; + // ── Multi-tenancy ────────────────────────────────────────────────────────── + @Column({ name: 'tenant_id', nullable: true }) tenantId: string | null; + // ── Timestamps ───────────────────────────────────────────────────────────── + @CreateDateColumn({ name: 'timestamp' }) timestamp: Date; + /** + * Absolute expiry date for this record. + * Null means the record is kept indefinitely (e.g. CRITICAL severity logs). + * Indexed — see class-level @Index. + */ @Column({ name: 'retention_until', nullable: true }) retentionUntil: Date | null; -} +} \ No newline at end of file diff --git a/src/audit-log/audit-log.service.ts b/src/audit-log/audit-log.service.ts index 2510dd66..ef8cb81e 100644 --- a/src/audit-log/audit-log.service.ts +++ b/src/audit-log/audit-log.service.ts @@ -20,13 +20,74 @@ export { IAuditReport, } from './interfaces/audit-log.interfaces'; +// ─── Option objects ──────────────────────────────────────────────────────────── +// +// Positional argument lists longer than 3 parameters are error-prone: callers +// must count positions and silently pass the wrong value if the order changes. +// Option objects make call sites self-documenting and allow optional fields to +// be omitted without placeholder `undefined` arguments. + +export interface LogAuthOptions { + action: AuditAction; + userId: string | null; + userEmail: string | null; + ipAddress: string; + userAgent: string; + metadata?: Record; + severity?: AuditSeverity; +} + +export interface LogDataChangeOptions { + action: AuditAction; + userId: string; + userEmail: string; + entityType: string; + entityId: string; + oldValues: Record; + newValues: Record; + ipAddress?: string; + description?: string; +} + +export interface LogApiAccessOptions { + userId: string | null; + userEmail: string | null; + apiEndpoint: string; + httpMethod: string; + statusCode: number; + responseTimeMs: number; + ipAddress: string; + userAgent: string; + requestId?: string; +} + +export interface LogSecurityEventOptions { + action: AuditAction; + userId: string | null; + userEmail: string | null; + ipAddress: string; + userAgent: string; + description: string; + metadata?: Record; +} + +export interface AuditStatistics { + totalLogs: number; + logsToday: number; + logsThisWeek: number; + logsThisMonth: number; + criticalEvents: number; + errorEvents: number; +} + /** - * Provides audit logging operations. - * Acts as a facade delegating to specialized services following Single Responsibility Principle. - * - AuditLoggerService: Handles logging operations - * - AuditQueryService: Handles search and query operations - * - AuditReportingService: Handles report generation and statistics - * - AuditExportService: Handles export to various formats + * Facade that routes audit operations to specialised sub-services. + * + * Responsibilities of sub-services: + * AuditLoggerService — write operations (log, retention) + * AuditQueryService — read operations (search, find*) + * AuditReportingService — aggregations and statistics + * AuditExportService — serialisation (JSON, CSV) */ @Injectable() export class AuditLogService { @@ -37,194 +98,121 @@ export class AuditLogService { private readonly exportService: AuditExportService, ) {} - /** - * Log an audit event - */ - async log(entry: IAuditLogEntry): Promise { + // ── Write ────────────────────────────────────────────────────────────────── + + log(entry: IAuditLogEntry): Promise { return this.loggerService.log(entry); } - /** - * Log authentication event - */ - async logAuth( - action: AuditAction, - userId: string | null, - userEmail: string | null, - ipAddress: string, - userAgent: string, - metadata?: Record, - severity: AuditSeverity = AuditSeverity.INFO, - ): Promise { - return this.loggerService.logAuth(action, userId, userEmail, ipAddress, userAgent, metadata, severity); - } - - /** - * Log data modification - */ - async logDataChange( - action: AuditAction, - userId: string, - userEmail: string, - entityType: string, - entityId: string, - oldValues: Record, - newValues: Record, - ipAddress?: string, - description?: string, - ): Promise { + logAuth(options: LogAuthOptions): Promise { + return this.loggerService.logAuth( + options.action, + options.userId, + options.userEmail, + options.ipAddress, + options.userAgent, + options.metadata, + options.severity ?? AuditSeverity.INFO, + ); + } + + logDataChange(options: LogDataChangeOptions): Promise { return this.loggerService.logDataChange( - action, - userId, - userEmail, - entityType, - entityId, - oldValues, - newValues, - ipAddress, - description, + options.action, + options.userId, + options.userEmail, + options.entityType, + options.entityId, + options.oldValues, + options.newValues, + options.ipAddress, + options.description, ); } - /** - * Log API access - */ - async logApiAccess( - userId: string | null, - userEmail: string | null, - apiEndpoint: string, - httpMethod: string, - statusCode: number, - responseTimeMs: number, - ipAddress: string, - userAgent: string, - requestId?: string, - ): Promise { + logApiAccess(options: LogApiAccessOptions): Promise { return this.loggerService.logApiAccess( - userId, - userEmail, - apiEndpoint, - httpMethod, - statusCode, - responseTimeMs, - ipAddress, - userAgent, - requestId, + options.userId, + options.userEmail, + options.apiEndpoint, + options.httpMethod, + options.statusCode, + options.responseTimeMs, + options.ipAddress, + options.userAgent, + options.requestId, ); } - /** - * Log security event - */ - async logSecurityEvent( - action: AuditAction, - userId: string | null, - userEmail: string | null, - ipAddress: string, - userAgent: string, - description: string, - metadata?: Record, - ): Promise { - return this.loggerService.logSecurityEvent(action, userId, userEmail, ipAddress, userAgent, description, metadata); - } - - /** - * Search audit logs with filters - */ - async search( + logSecurityEvent(options: LogSecurityEventOptions): Promise { + return this.loggerService.logSecurityEvent( + options.action, + options.userId, + options.userEmail, + options.ipAddress, + options.userAgent, + options.description, + options.metadata, + ); + } + + // ── Query ────────────────────────────────────────────────────────────────── + + search( filters: IAuditLogSearchFilters, - page: number = 1, - limit: number = 50, + page = 1, + limit = 50, ): Promise { return this.queryService.search(filters, page, limit); } - /** - * Find all logs (with limit) - */ - async findAll(limit: number = 100): Promise { + findAll(limit = 100): Promise { return this.queryService.findAll(limit); } - /** - * Find logs by user - */ - async findByUser(userId: string, limit: number = 100): Promise { + findByUser(userId: string, limit = 100): Promise { return this.queryService.findByUser(userId, limit); } - /** - * Find logs by action - */ - async findByAction(action: AuditAction, limit: number = 100): Promise { + findByAction(action: AuditAction, limit = 100): Promise { return this.queryService.findByAction(action, limit); } - /** - * Find logs by entity - */ - async findByEntity( - entityType: string, - entityId: string, - limit: number = 100, - ): Promise { + findByEntity(entityType: string, entityId: string, limit = 100): Promise { return this.queryService.findByEntity(entityType, entityId, limit); } - /** - * Find logs by IP address - */ - async findByIpAddress(ipAddress: string, limit: number = 100): Promise { + findByIpAddress(ipAddress: string, limit = 100): Promise { return this.queryService.findByIpAddress(ipAddress, limit); } - /** - * Find logs by date range - */ - async findByDateRange(startDate: Date, endDate: Date, limit: number = 1000): Promise { + findByDateRange(startDate: Date, endDate: Date, limit = 1000): Promise { return this.queryService.findByDateRange(startDate, endDate, limit); } - /** - * Generate audit report - */ - async generateReport(startDate: Date, endDate: Date): Promise { + // ── Reporting ────────────────────────────────────────────────────────────── + + generateReport(startDate: Date, endDate: Date): Promise { return this.reportingService.generateReport(startDate, endDate); } - /** - * Apply retention policy - delete old logs - */ - async applyRetentionPolicy(): Promise { + getStatistics(): Promise { + return this.reportingService.getStatistics(); + } + + // ── Maintenance ──────────────────────────────────────────────────────────── + + applyRetentionPolicy(): Promise { return this.loggerService.applyRetentionPolicy(); } - /** - * Export logs to JSON - */ - async exportToJson(filters: IAuditLogSearchFilters): Promise { + // ── Export ───────────────────────────────────────────────────────────────── + + exportToJson(filters: IAuditLogSearchFilters): Promise { return this.exportService.exportToJson(filters); } - /** - * Export logs to CSV - */ - async exportToCsv(filters: IAuditLogSearchFilters): Promise { + exportToCsv(filters: IAuditLogSearchFilters): Promise { return this.exportService.exportToCsv(filters); } - - /** - * Get statistics - */ - async getStatistics(): Promise<{ - totalLogs: number; - logsToday: number; - logsThisWeek: number; - logsThisMonth: number; - criticalEvents: number; - errorEvents: number; - }> { - return this.reportingService.getStatistics(); - } -} - +} \ No newline at end of file diff --git a/src/caching/cache-invalidation.listener.spec.ts b/src/caching/cache-invalidation.listener.spec.ts index fcf59c8b..bf60ebbd 100644 --- a/src/caching/cache-invalidation.listener.spec.ts +++ b/src/caching/cache-invalidation.listener.spec.ts @@ -2,48 +2,108 @@ import { CacheInvalidationListener } from './cache-invalidation.listener'; import { CacheInvalidationService } from './cache-invalidation.service'; import { CACHE_EVENTS } from './caching.constants'; +const makeMockInvalidation = () => ({ + invalidateCourseCache: jest.fn().mockResolvedValue(undefined), + invalidateUserCache: jest.fn().mockResolvedValue(undefined), + invalidatePattern: jest.fn().mockResolvedValue(undefined), +}); + describe('CacheInvalidationListener', () => { let listener: CacheInvalidationListener; - let invalidation: { - invalidateCourseCache: jest.Mock; - invalidateUserCache: jest.Mock; - invalidatePattern: jest.Mock; - }; + let invalidation: ReturnType; beforeEach(() => { - invalidation = { - invalidateCourseCache: jest.fn().mockResolvedValue(undefined), - invalidateUserCache: jest.fn().mockResolvedValue(undefined), - invalidatePattern: jest.fn().mockResolvedValue(undefined), - }; + invalidation = makeMockInvalidation(); listener = new CacheInvalidationListener( invalidation as unknown as CacheInvalidationService, ); }); - it('invalidates course caches on course update events', async () => { - await listener.onCourseChange({ id: 'course-1' }); - expect(invalidation.invalidateCourseCache).toHaveBeenCalledWith('course-1'); + // ── CACHE_EVENTS constants ───────────────────────────────────────────────── + + describe('CACHE_EVENTS constants', () => { + it('defines all expected event name values', () => { + expect(CACHE_EVENTS.COURSE_UPDATED).toBe('cache.course.updated'); + expect(CACHE_EVENTS.USER_UPDATED).toBe('cache.user.updated'); + expect(CACHE_EVENTS.ENROLLMENT_CHANGED).toBe('cache.enrollment.changed'); + expect(CACHE_EVENTS.SEARCH_INDEX_UPDATED).toBe('cache.search.index.updated'); + }); }); - it('invalidates user profile caches on user update events', async () => { - await listener.onUserChange({ id: 'user-1' }); - expect(invalidation.invalidateUserCache).toHaveBeenCalledWith('user-1'); + // ── onCourseChange ───────────────────────────────────────────────────────── + + describe('onCourseChange', () => { + it('invalidates the course cache for the given id', async () => { + await listener.onCourseChange({ id: 'course-1' }); + expect(invalidation.invalidateCourseCache).toHaveBeenCalledWith('course-1'); + }); + + it('does not touch user or pattern caches', async () => { + await listener.onCourseChange({ id: 'course-1' }); + expect(invalidation.invalidateUserCache).not.toHaveBeenCalled(); + expect(invalidation.invalidatePattern).not.toHaveBeenCalled(); + }); + + it('propagates with an undefined id without throwing', async () => { + await expect(listener.onCourseChange({ id: undefined as any })).resolves.not.toThrow(); + expect(invalidation.invalidateCourseCache).toHaveBeenCalledWith(undefined); + }); }); - it('invalidates list and popular caches on enrollment events', async () => { - await listener.onEnrollmentChange({ id: 'enrollment-1' }); - expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:courses:list:*'); - expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:popular:*'); + // ── onUserChange ─────────────────────────────────────────────────────────── + + describe('onUserChange', () => { + it('invalidates the user cache for the given id', async () => { + await listener.onUserChange({ id: 'user-1' }); + expect(invalidation.invalidateUserCache).toHaveBeenCalledWith('user-1'); + }); + + it('does not touch course or pattern caches', async () => { + await listener.onUserChange({ id: 'user-1' }); + expect(invalidation.invalidateCourseCache).not.toHaveBeenCalled(); + expect(invalidation.invalidatePattern).not.toHaveBeenCalled(); + }); + + it('propagates with an undefined id without throwing', async () => { + await expect(listener.onUserChange({ id: undefined as any })).resolves.not.toThrow(); + expect(invalidation.invalidateUserCache).toHaveBeenCalledWith(undefined); + }); }); - it('invalidates search caches when search index updates', async () => { - await listener.onSearchIndexUpdated(); - expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:search:*'); + // ── onEnrollmentChange ───────────────────────────────────────────────────── + + describe('onEnrollmentChange', () => { + it('invalidates the course list cache pattern', async () => { + await listener.onEnrollmentChange({ id: 'enrollment-1' }); + expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:courses:list:*'); + }); + + it('invalidates the popular content cache pattern', async () => { + await listener.onEnrollmentChange({ id: 'enrollment-1' }); + expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:popular:*'); + }); + + it('invalidates exactly two patterns and nothing else', async () => { + await listener.onEnrollmentChange({ id: 'enrollment-1' }); + expect(invalidation.invalidatePattern).toHaveBeenCalledTimes(2); + expect(invalidation.invalidateCourseCache).not.toHaveBeenCalled(); + expect(invalidation.invalidateUserCache).not.toHaveBeenCalled(); + }); }); - it('responds to documented cache event constants', () => { - expect(CACHE_EVENTS.COURSE_UPDATED).toBe('cache.course.updated'); - expect(CACHE_EVENTS.USER_UPDATED).toBe('cache.user.updated'); + // ── onSearchIndexUpdated ─────────────────────────────────────────────────── + + describe('onSearchIndexUpdated', () => { + it('invalidates the search cache pattern', async () => { + await listener.onSearchIndexUpdated(); + expect(invalidation.invalidatePattern).toHaveBeenCalledWith('cache:search:*'); + }); + + it('invalidates exactly one pattern and nothing else', async () => { + await listener.onSearchIndexUpdated(); + expect(invalidation.invalidatePattern).toHaveBeenCalledTimes(1); + expect(invalidation.invalidateCourseCache).not.toHaveBeenCalled(); + expect(invalidation.invalidateUserCache).not.toHaveBeenCalled(); + }); }); -}); +}); \ No newline at end of file