From e3eb5e0528e363e6feaad7a1f823535cd5f2daa6 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Thu, 26 Mar 2026 16:53:56 +0100 Subject: [PATCH 1/2] Conditional-Middleware-Execution --- backend/package.json | 2 + backend/src/common/middleware/utils/README.md | 150 ++++++++ ...conditional.middleware.integration.spec.ts | 304 ++++++++++++++++ .../utils/conditional.middleware.spec.ts | 330 ++++++++++++++++++ .../utils/conditional.middleware.ts | 87 +++++ backend/src/index.ts | 3 + ...conditional.middleware.integration.spec.ts | 238 +++++++++++++ package-lock.json | 23 +- 8 files changed, 1131 insertions(+), 6 deletions(-) create mode 100644 backend/src/common/middleware/utils/README.md create mode 100644 backend/src/common/middleware/utils/conditional.middleware.integration.spec.ts create mode 100644 backend/src/common/middleware/utils/conditional.middleware.spec.ts create mode 100644 backend/src/common/middleware/utils/conditional.middleware.ts create mode 100644 backend/src/index.ts create mode 100644 backend/test/conditional.middleware.integration.spec.ts diff --git a/backend/package.json b/backend/package.json index daa2f36..e20023b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "@nestjs/swagger": "^11.2.5", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", + "@types/micromatch": "^4.0.10", "@types/passport-google-oauth20": "^2.0.16", "@types/pdfkit": "^0.14.0", "bcryptjs": "^3.0.2", @@ -43,6 +44,7 @@ "google-auth-library": "^9.15.1", "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", + "micromatch": "^4.0.8", "nodemailer": "^7.0.12", "oauth2client": "^1.0.0", "passport": "^0.7.0", diff --git a/backend/src/common/middleware/utils/README.md b/backend/src/common/middleware/utils/README.md new file mode 100644 index 0000000..0571a3d --- /dev/null +++ b/backend/src/common/middleware/utils/README.md @@ -0,0 +1,150 @@ +# Conditional Middleware Utilities + +This module provides higher-order middleware wrappers that allow you to conditionally apply middleware based on route patterns. + +## Installation + +The utilities are exported from `src/index.ts`: + +```typescript +import { unless, onlyFor, RoutePattern } from '@/index'; +``` + +## Usage + +### `unless(middleware, excludePatterns)` + +Skips middleware execution for routes matching the provided patterns. + +```typescript +import { unless } from '@/index'; +import { CorrelationIdMiddleware } from './correlation-id.middleware'; + +// Skip correlation ID for health and metrics endpoints +const conditionalMiddleware = unless( + new CorrelationIdMiddleware(), + ['/health', '/metrics', '/api/*/health'] +); + +// Apply in your module +app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); +``` + +### `onlyFor(middleware, includePatterns)` + +Executes middleware only for routes matching the provided patterns. + +```typescript +import { onlyFor } from '@/index'; +import { AuthMiddleware } from './auth.middleware'; + +// Apply auth middleware only to admin routes +const conditionalMiddleware = onlyFor( + new AuthMiddleware(), + ['/api/admin/*', '/admin/**'] +); + +// Apply in your module +app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); +``` + +## Pattern Types + +The utilities support three types of patterns: + +### 1. Exact Strings +```typescript +unless(middleware, '/health') +``` + +### 2. Regular Expressions +```typescript +unless(middleware, /^\/api\/v\d+\/status$/) +``` + +### 3. Glob Patterns +```typescript +unless(middleware, [ + '/api/*/metrics', + '/static/**', + '/admin/**/users/**' +]) +``` + +## Examples + +### Skip middleware for static assets +```typescript +const conditionalMiddleware = unless( + new LoggingMiddleware(), + [ + '/static/**', + '/assets/**', + '/**/*.css', + '/**/*.js', + '/**/*.png', + '/**/*.jpg' + ] +); +``` + +### Apply middleware only to API routes +```typescript +const conditionalMiddleware = onlyFor( + new RateLimitMiddleware(), + [ + '/api/**', + '!/api/docs/**' // Exclude API docs + ] +); +``` + +### Complex routing scenarios +```typescript +// Skip authentication for public routes +const publicRoutes = [ + '/health', + '/metrics', + '/auth/login', + '/auth/register', + '/public/**', + '/api/v1/public/**' +]; + +const conditionalAuth = unless( + new AuthMiddleware(), + publicRoutes +); +``` + +## Performance + +The conditional middleware is designed to have minimal overhead: + +- Zero overhead for non-matching routes (early return) +- Efficient pattern matching using micromatch +- Stateless implementation +- No memory leaks + +## Error Handling + +The utilities gracefully handle: + +- Invalid patterns (treated as non-matching) +- Null/undefined patterns (treated as non-matching) +- Malformed regex patterns (fallback to string comparison) +- Empty pattern arrays (treated as non-matching) + +## TypeScript Support + +Full TypeScript support with proper type definitions: + +```typescript +import { RoutePattern } from '@/index'; + +const patterns: RoutePattern = [ + '/api/users', // string + /^\/api\/v\d+/, // regex + '/admin/**' // glob +]; +``` diff --git a/backend/src/common/middleware/utils/conditional.middleware.integration.spec.ts b/backend/src/common/middleware/utils/conditional.middleware.integration.spec.ts new file mode 100644 index 0000000..9322635 --- /dev/null +++ b/backend/src/common/middleware/utils/conditional.middleware.integration.spec.ts @@ -0,0 +1,304 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { unless, onlyFor } from './conditional.middleware'; + +// Simple test middleware for integration testing +@Injectable() +class TestLoggingMiddleware implements NestMiddleware { + public static calls: Array<{ path: string; timestamp: number }> = []; + + use(req: Request, res: Response, next: NextFunction): void { + TestLoggingMiddleware.calls.push({ + path: req.path || req.url || '/', + timestamp: Date.now(), + }); + next(); + } + + static reset(): void { + TestLoggingMiddleware.calls = []; + } +} + +// Second test middleware for chaining tests +@Injectable() +class TestAuthMiddleware implements NestMiddleware { + public static calls: Array<{ path: string; timestamp: number }> = []; + + use(req: Request, res: Response, next: NextFunction): void { + TestAuthMiddleware.calls.push({ + path: req.path || req.url || '/', + timestamp: Date.now(), + }); + next(); + } + + static reset(): void { + TestAuthMiddleware.calls = []; + } +} + +describe('Conditional Middleware Integration Tests', () => { + let middleware: TestLoggingMiddleware; + let mockReq: any; + let mockRes: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + TestLoggingMiddleware.reset(); + middleware = new TestLoggingMiddleware(); + + mockRes = { + setHeader: jest.fn(), + getHeader: jest.fn(), + }; + mockNext = jest.fn(); + }); + + describe('Real-world usage scenarios', () => { + it('should work with typical API route patterns', () => { + // Skip logging for health and metrics endpoints + const conditionalMiddleware = unless(middleware, [ + '/health', + '/metrics', + '/api/*/health', + '/api/*/metrics', + ]); + + // Test various routes + const routes = [ + { path: '/health', shouldLog: false }, + { path: '/metrics', shouldLog: false }, + { path: '/api/v1/health', shouldLog: false }, + { path: '/api/v2/metrics', shouldLog: false }, + { path: '/api/v1/users', shouldLog: true }, + { path: '/api/v2/posts', shouldLog: true }, + { path: '/auth/login', shouldLog: true }, + ]; + + routes.forEach((route) => { + mockReq = { path: route.path, url: route.path }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + }); + + const loggedPaths = TestLoggingMiddleware.calls.map((call) => call.path); + const expectedLoggedPaths = routes + .filter((route) => route.shouldLog) + .map((route) => route.path); + + expect(loggedPaths).toEqual(expectedLoggedPaths); + expect(loggedPaths).not.toContain('/health'); + expect(loggedPaths).not.toContain('/metrics'); + }); + + it('should handle admin-only middleware with onlyFor', () => { + // Only apply logging middleware to admin routes + const conditionalMiddleware = onlyFor(middleware, [ + '/api/admin/*', + '/api/v*/admin/**', + /^\/admin\//, + ]); + + const routes = [ + { path: '/api/admin/users', shouldLog: true }, + { path: '/api/v1/admin/settings', shouldLog: true }, + { path: '/admin/dashboard', shouldLog: true }, + { path: '/api/v1/users', shouldLog: false }, + { path: '/api/v2/posts', shouldLog: false }, + { path: '/public/home', shouldLog: false }, + ]; + + routes.forEach((route) => { + mockReq = { path: route.path, url: route.path }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + }); + + const loggedPaths = TestLoggingMiddleware.calls.map((call) => call.path); + const expectedLoggedPaths = routes + .filter((route) => route.shouldLog) + .map((route) => route.path); + + expect(loggedPaths).toEqual(expectedLoggedPaths); + expect(loggedPaths).toContain('/api/admin/users'); + expect(loggedPaths).not.toContain('/api/v1/users'); + }); + + it('should handle complex routing scenarios', () => { + // Skip middleware for static assets and API documentation + const conditionalMiddleware = unless(middleware, [ + '/static/**', + '/assets/**', + '/docs/**', + '/api/docs/**', + '/swagger/**', + /\.(css|js|ico|png|jpg|jpeg|gif|svg)$/, // Static file extensions + ]); + + const routes = [ + { path: '/static/css/main.css', shouldLog: false }, + { path: '/assets/images/logo.png', shouldLog: false }, + { path: '/docs/api.html', shouldLog: false }, + { path: '/api/docs/v1', shouldLog: false }, + { path: '/swagger/ui', shouldLog: false }, + { path: '/favicon.ico', shouldLog: false }, + { path: '/api/v1/users', shouldLog: true }, + { path: '/auth/login', shouldLog: true }, + { path: '/dashboard', shouldLog: true }, + ]; + + routes.forEach((route) => { + mockReq = { path: route.path, url: route.path }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + }); + + const loggedPaths = TestLoggingMiddleware.calls.map((call) => call.path); + const expectedLoggedPaths = routes + .filter((route) => route.shouldLog) + .map((route) => route.path); + + expect(loggedPaths).toEqual(expectedLoggedPaths); + expect(loggedPaths).not.toContain('/static/css/main.css'); + expect(loggedPaths).not.toContain('/favicon.ico'); + expect(loggedPaths).toContain('/api/v1/users'); + }); + }); + + describe('Edge cases and error handling', () => { + it('should handle empty pattern arrays', () => { + const conditionalMiddleware = unless(middleware, [] as any); + + mockReq = { path: '/test', url: '/test' }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + + expect(TestLoggingMiddleware.calls).toHaveLength(1); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should handle null/undefined patterns gracefully', () => { + expect(() => { + const conditionalMiddleware1 = unless(middleware, null as any); + const conditionalMiddleware2 = onlyFor(middleware, undefined as any); + + mockReq = { path: '/test', url: '/test' }; + conditionalMiddleware1.use(mockReq, mockRes as Response, mockNext); + conditionalMiddleware2.use(mockReq, mockRes as Response, mockNext); + }).not.toThrow(); + }); + + it('should handle malformed regex patterns', () => { + expect(() => { + const conditionalMiddleware = unless(middleware, [ + /invalid regex/ as any, + ]); + mockReq = { path: '/test', url: '/test' }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + }).not.toThrow(); + }); + + it('should handle very long paths', () => { + const longPath = '/api/v1/' + 'segment/'.repeat(100) + 'endpoint'; + const conditionalMiddleware = unless(middleware, ['/api/v1/**'] as any); + + mockReq = { path: longPath, url: longPath }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + + expect(TestLoggingMiddleware.calls).toHaveLength(0); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('Performance under load', () => { + it('should handle large numbers of route patterns efficiently', () => { + // Create a large array of patterns + const patterns: any[] = []; + for (let i = 0; i < 1000; i++) { + patterns.push(`/api/v${i}/**`); + } + patterns.push('/health', '/metrics'); + + const conditionalMiddleware = unless(middleware, patterns); + + const start = Date.now(); + + // Test with many routes + for (let i = 0; i < 1000; i++) { + mockReq = { path: `/api/v500/test${i}`, url: `/api/v500/test${i}` }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + TestLoggingMiddleware.reset(); + } + + const end = Date.now(); + const duration = end - start; + + // Should complete quickly even with many patterns + expect(duration).toBeLessThan(10000); + }); + + it('should maintain performance with nested glob patterns', () => { + const complexPatterns: any[] = [ + '/api/**/users/**', + '/api/**/posts/**', + '/api/**/comments/**', + '/admin/**/settings/**', + '/admin/**/users/**', + ]; + + const conditionalMiddleware = onlyFor(middleware, complexPatterns); + + const start = Date.now(); + + const testRoutes = [ + '/api/v1/users/123/posts', + '/api/v2/posts/456/comments', + '/admin/panel/settings/general', + '/api/v1/users/789/profile', + '/admin/dashboard/users/list', + ]; + + testRoutes.forEach((route) => { + mockReq = { path: route, url: route }; + conditionalMiddleware.use(mockReq, mockRes as Response, mockNext); + TestLoggingMiddleware.reset(); + }); + + const end = Date.now(); + const duration = end - start; + + // Should handle complex patterns efficiently + expect(duration).toBeLessThan(50); + }); + }); + + describe('Middleware chaining', () => { + it('should work correctly when multiple conditional middlewares are chained', () => { + // First middleware: skip for health routes + const firstConditional = unless(middleware, ['/health']); + + // Second middleware: only for admin routes + const secondMiddleware = new TestAuthMiddleware(); + const secondConditional = onlyFor(secondMiddleware, ['/admin/**']); + + const routes = [ + { path: '/health', firstShouldLog: false, secondShouldLog: false }, + { path: '/admin/users', firstShouldLog: true, secondShouldLog: true }, + { path: '/api/users', firstShouldLog: true, secondShouldLog: false }, + ]; + + routes.forEach((route) => { + TestLoggingMiddleware.reset(); + TestAuthMiddleware.reset(); + + mockReq = { path: route.path, url: route.path }; + + firstConditional.use(mockReq, mockRes as Response, mockNext); + const firstLogged = TestLoggingMiddleware.calls.length > 0; + + secondConditional.use(mockReq, mockRes as Response, mockNext); + const secondLogged = TestAuthMiddleware.calls.length > 0; + + expect(firstLogged).toBe(route.firstShouldLog); + expect(secondLogged).toBe(route.secondShouldLog); + }); + }); + }); +}); diff --git a/backend/src/common/middleware/utils/conditional.middleware.spec.ts b/backend/src/common/middleware/utils/conditional.middleware.spec.ts new file mode 100644 index 0000000..b742613 --- /dev/null +++ b/backend/src/common/middleware/utils/conditional.middleware.spec.ts @@ -0,0 +1,330 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { unless, onlyFor, RoutePattern } from './conditional.middleware'; + +// Mock middleware for testing +@Injectable() +class TestMiddleware implements NestMiddleware { + public static callCount = 0; + public static lastPath: string = ''; + + use(req: Request, res: Response, next: NextFunction): void { + TestMiddleware.callCount++; + TestMiddleware.lastPath = req.path || req.url || '/'; + next(); + } + + static reset(): void { + TestMiddleware.callCount = 0; + TestMiddleware.lastPath = ''; + } +} + +describe('Conditional Middleware', () => { + let middleware: TestMiddleware; + let mockReq: any; + let mockRes: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + TestMiddleware.reset(); + middleware = new TestMiddleware(); + + mockReq = { + path: '/test', + url: '/test', + }; + + mockRes = {}; + mockNext = jest.fn(); + }); + + describe('matchesPath', () => { + const testMatchesPath = ( + path: string, + pattern: RoutePattern, + expected: boolean, + ) => { + // Import the private function through reflection for testing + const conditionalMiddleware = require('./conditional.middleware'); + + // We'll test the public behavior through unless/onlyFor instead + }; + + describe('exact string matching', () => { + it('should match exact strings', async () => { + const wrappedMiddleware = unless(middleware, '/health'); + + mockReq.path = '/health'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(0); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should not match different strings', async () => { + const wrappedMiddleware = unless(middleware, '/health'); + + mockReq.path = '/api/users'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(1); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('regex pattern matching', () => { + it('should match regex patterns', async () => { + const wrappedMiddleware = unless(middleware, /^\/health/); + + mockReq.path = '/health/detailed'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(0); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should not match non-matching regex', async () => { + const wrappedMiddleware = unless(middleware, /^\/health/); + + mockReq.path = '/api/users'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(1); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('glob pattern matching', () => { + it('should match glob patterns', async () => { + const wrappedMiddleware = unless(middleware, '/api/*/users'); + + mockReq.path = '/api/v1/users'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(0); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should match complex glob patterns', async () => { + const wrappedMiddleware = unless(middleware, '/api/**/metrics'); + + mockReq.path = '/api/v1/system/metrics'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(0); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should not match non-matching glob patterns', async () => { + const wrappedMiddleware = unless(middleware, '/api/*/users'); + + mockReq.path = '/api/v1/posts'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(1); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('array of patterns', () => { + it('should match any pattern in array', async () => { + const wrappedMiddleware = unless(middleware, [ + '/health', + '/metrics', + /^\/api\/v\d+\/status/, + ]); + + // Test first pattern + mockReq.path = '/health'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + expect(TestMiddleware.callCount).toBe(0); + + TestMiddleware.reset(); + + // Test second pattern + mockReq.path = '/metrics'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + expect(TestMiddleware.callCount).toBe(0); + + TestMiddleware.reset(); + + // Test regex pattern + mockReq.path = '/api/v2/status'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + expect(TestMiddleware.callCount).toBe(0); + }); + + it('should not match when no patterns match', async () => { + const wrappedMiddleware = unless(middleware, ['/health', '/metrics']); + + mockReq.path = '/api/users'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + + expect(TestMiddleware.callCount).toBe(1); + expect(mockNext).toHaveBeenCalled(); + }); + }); + }); + + describe('unless', () => { + it('should skip middleware for excluded routes', async () => { + const wrappedMiddleware = unless(middleware, '/health'); + + mockReq.path = '/health'; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(0); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should execute middleware for non-excluded routes', async () => { + const wrappedMiddleware = unless(middleware, '/health'); + + mockReq.path = '/api/users'; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(1); + expect(TestMiddleware.lastPath).toBe('/api/users'); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should handle req.url fallback when req.path is undefined', async () => { + const wrappedMiddleware = unless(middleware, '/health'); + + delete mockReq.path; + mockReq.url = '/health'; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(0); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should handle fallback to root path when both are undefined', async () => { + const wrappedMiddleware = unless(middleware, '/health'); + + delete mockReq.path; + delete mockReq.url; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(1); + expect(TestMiddleware.lastPath).toBe('/'); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('onlyFor', () => { + it('should execute middleware only for specified routes', async () => { + const wrappedMiddleware = onlyFor(middleware, '/api/v1/users'); + + mockReq.path = '/api/v1/users'; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(1); + expect(TestMiddleware.lastPath).toBe('/api/v1/users'); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should skip middleware for non-specified routes', async () => { + const wrappedMiddleware = onlyFor(middleware, '/api/v1/users'); + + mockReq.path = '/api/v1/posts'; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(0); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should work with regex patterns', async () => { + const wrappedMiddleware = onlyFor(middleware, /^\/api\/v\d+\/users/); + + mockReq.path = '/api/v2/users'; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(1); + expect(TestMiddleware.lastPath).toBe('/api/v2/users'); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should work with glob patterns', async () => { + const wrappedMiddleware = onlyFor(middleware, '/api/*/users'); + + mockReq.path = '/api/v1/users'; + wrappedMiddleware.use(mockReq as Request, mockRes as Response, mockNext); + + expect(TestMiddleware.callCount).toBe(1); + expect(TestMiddleware.lastPath).toBe('/api/v1/users'); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('performance', () => { + it('should have minimal overhead for non-matching routes', async () => { + const wrappedMiddleware = unless(middleware, '/health'); + + const start = process.hrtime.bigint(); + + // Run many iterations to measure overhead + for (let i = 0; i < 10000; i++) { + mockReq.path = '/api/users'; + wrappedMiddleware.use( + mockReq as Request, + mockRes as Response, + mockNext, + ); + TestMiddleware.reset(); + } + + const end = process.hrtime.bigint(); + const duration = Number(end - start) / 1000000; // Convert to milliseconds + + // Should complete very quickly (less than 100ms for 10k iterations) + expect(duration).toBeLessThan(100); + }); + }); +}); diff --git a/backend/src/common/middleware/utils/conditional.middleware.ts b/backend/src/common/middleware/utils/conditional.middleware.ts new file mode 100644 index 0000000..35448f0 --- /dev/null +++ b/backend/src/common/middleware/utils/conditional.middleware.ts @@ -0,0 +1,87 @@ +import { NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import * as micromatch from 'micromatch'; + +export type RoutePattern = string | RegExp | (string | RegExp)[]; + +/** + * Checks if the request path matches any of the provided patterns + */ +function matchesPath(path: string, patterns: RoutePattern): boolean { + if (!patterns || (Array.isArray(patterns) && patterns.length === 0)) { + return false; + } + + if (Array.isArray(patterns)) { + return patterns.some((pattern) => matchesPath(path, pattern)); + } + + if (patterns instanceof RegExp) { + return patterns.test(path); + } + + // Handle empty strings and invalid patterns + if (typeof patterns !== 'string' || patterns.trim() === '') { + return false; + } + + // Handle glob patterns and exact strings with micromatch + try { + return micromatch.isMatch(path, patterns); + } catch (error) { + // If micromatch fails, fall back to exact string comparison + return path === patterns; + } +} + +/** + * Higher-order middleware wrapper that skips execution for specified routes + * + * @param middleware - The NestJS middleware to wrap + * @param excludePatterns - Route patterns to exclude (string, regex, or glob) + * @returns Wrapped middleware that skips execution for matching routes + */ +export function unless( + middleware: T, + excludePatterns: RoutePattern, +): T { + return new (class { + use(req: Request, res: Response, next: NextFunction): void { + const requestPath = req.path || req.url || '/'; + + // If path matches exclude patterns, skip middleware + if (matchesPath(requestPath, excludePatterns)) { + return next(); + } + + // Otherwise, execute the original middleware + return middleware.use(req, res, next); + } + })() as T; +} + +/** + * Higher-order middleware wrapper that executes only for specified routes + * + * @param middleware - The NestJS middleware to wrap + * @param includePatterns - Route patterns to include (string, regex, or glob) + * @returns Wrapped middleware that executes only for matching routes + */ +export function onlyFor( + middleware: T, + includePatterns: RoutePattern, +): T { + return new (class { + use(req: Request, res: Response, next: NextFunction): void { + const requestPath = req.path || req.url || '/'; + + // If path doesn't match include patterns, skip middleware + if (!matchesPath(requestPath, includePatterns)) { + return next(); + } + + // Otherwise, execute the original middleware + return middleware.use(req, res, next); + } + })() as T; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..69f7f59 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,3 @@ +// Export conditional middleware utilities +export { unless, onlyFor } from './common/middleware/utils/conditional.middleware'; +export type { RoutePattern } from './common/middleware/utils/conditional.middleware'; diff --git a/backend/test/conditional.middleware.integration.spec.ts b/backend/test/conditional.middleware.integration.spec.ts new file mode 100644 index 0000000..53db73f --- /dev/null +++ b/backend/test/conditional.middleware.integration.spec.ts @@ -0,0 +1,238 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { CorrelationIdMiddleware } from '../src/common/middleware/correlation-id.middleware'; +import { + unless, + onlyFor, +} from '../src/common/middleware/utils/conditional.middleware'; + +describe('Conditional Middleware Integration Tests', () => { + let app: INestApplication; + let correlationMiddleware: CorrelationIdMiddleware; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + correlationMiddleware = new CorrelationIdMiddleware(); + + // Set up global prefix to match main.ts + app.setGlobalPrefix('api', { + exclude: ['health', 'health/*path'], + }); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('unless() integration', () => { + it('should skip correlation middleware for health endpoint', async () => { + // Apply conditional middleware to skip correlation ID for health routes + const conditionalMiddleware = unless(correlationMiddleware, [ + '/health', + '/api/health', + ]); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + const response = await request(app.getHttpServer()) + .get('/health') + .expect(200); + + // Health endpoint should not have correlation ID header when skipped + expect(response.headers['x-correlation-id']).toBeUndefined(); + }); + + it('should apply correlation middleware for non-excluded routes', async () => { + // Apply conditional middleware to skip correlation ID for health routes only + const conditionalMiddleware = unless(correlationMiddleware, ['/health']); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + const response = await request(app.getHttpServer()) + .get('/api/v1/users') + .expect(404); // We expect 404 since the route doesn't exist, but middleware should run + + // Non-excluded endpoint should have correlation ID header + expect(response.headers['x-correlation-id']).toBeDefined(); + expect(typeof response.headers['x-correlation-id']).toBe('string'); + }); + + it('should work with glob patterns', async () => { + // Apply conditional middleware to skip correlation ID for API metrics routes + const conditionalMiddleware = unless(correlationMiddleware, [ + '/api/*/metrics', + ]); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + const response = await request(app.getHttpServer()) + .get('/api/v1/metrics') + .expect(404); // Route doesn't exist but should be skipped + + // Metrics endpoint should not have correlation ID header when skipped + expect(response.headers['x-correlation-id']).toBeUndefined(); + }); + + it('should work with regex patterns', async () => { + // Apply conditional middleware to skip correlation ID for status routes + const conditionalMiddleware = unless(correlationMiddleware, [ + /^\/api\/v\d+\/status$/, + ]); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + const response = await request(app.getHttpServer()) + .get('/api/v2/status') + .expect(404); // Route doesn't exist but should be skipped + + // Status endpoint should not have correlation ID header when skipped + expect(response.headers['x-correlation-id']).toBeUndefined(); + }); + }); + + describe('onlyFor() integration', () => { + it('should apply correlation middleware only for specified routes', async () => { + // Apply conditional middleware to only run correlation ID for admin routes + const conditionalMiddleware = onlyFor(correlationMiddleware, [ + '/api/admin/*', + ]); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + // Admin route should have correlation ID + const adminResponse = await request(app.getHttpServer()) + .get('/api/admin/users') + .expect(404); // Route doesn't exist but middleware should run + + expect(adminResponse.headers['x-correlation-id']).toBeDefined(); + + // Regular route should not have correlation ID + const userResponse = await request(app.getHttpServer()) + .get('/api/v1/users') + .expect(404); // Route doesn't exist and middleware should be skipped + + expect(userResponse.headers['x-correlation-id']).toBeUndefined(); + }); + + it('should work with multiple patterns', async () => { + // Apply conditional middleware to only run for admin and billing routes + const conditionalMiddleware = onlyFor(correlationMiddleware, [ + '/api/admin/*', + '/api/billing/*', + /^\/api\/v\d+\/audit/, + ]); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + // Test admin route + const adminResponse = await request(app.getHttpServer()) + .get('/api/admin/users') + .expect(404); + + expect(adminResponse.headers['x-correlation-id']).toBeDefined(); + + // Test billing route + const billingResponse = await request(app.getHttpServer()) + .get('/api/billing/invoices') + .expect(404); + + expect(billingResponse.headers['x-correlation-id']).toBeDefined(); + + // Test audit route with regex + const auditResponse = await request(app.getHttpServer()) + .get('/api/v2/audit/logs') + .expect(404); + + expect(auditResponse.headers['x-correlation-id']).toBeDefined(); + + // Test non-matching route + const userResponse = await request(app.getHttpServer()) + .get('/api/v1/users') + .expect(404); + + expect(userResponse.headers['x-correlation-id']).toBeUndefined(); + }); + }); + + describe('Performance Integration', () => { + it('should have minimal overhead for conditional middleware', async () => { + const conditionalMiddleware = unless(correlationMiddleware, [ + '/health', + '/metrics', + ]); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + const start = Date.now(); + + // Make multiple requests to test performance + const promises: Promise[] = []; + for (let i = 0; i < 100; i++) { + promises.push(request(app.getHttpServer()).get('/api/test')); + } + + await Promise.all(promises); + const end = Date.now(); + + const duration = end - start; + + // Should complete quickly (less than 1 second for 100 requests) + expect(duration).toBeLessThan(1000); + }); + + it('should handle high concurrency without issues', async () => { + const conditionalMiddleware = onlyFor(correlationMiddleware, [ + '/api/secure/*', + ]); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + + // Test concurrent requests to both matching and non-matching routes + const promises: Promise[] = []; + for (let i = 0; i < 50; i++) { + promises.push(request(app.getHttpServer()).get('/api/secure/data')); + promises.push(request(app.getHttpServer()).get('/api/public/data')); + } + + const responses = await Promise.all(promises); + + // Secure routes should have correlation ID + const secureResponses = responses.slice(0, 50); + secureResponses.forEach((response: any) => { + expect(response.headers['x-correlation-id']).toBeDefined(); + }); + + // Public routes should not have correlation ID + const publicResponses = responses.slice(50); + publicResponses.forEach((response: any) => { + expect(response.headers['x-correlation-id']).toBeUndefined(); + }); + }); + }); + + describe('Error Handling', () => { + it('should handle malformed patterns gracefully', async () => { + // This should not throw an error + expect(() => { + const conditionalMiddleware = unless(correlationMiddleware, ['']); + app.use(conditionalMiddleware.use.bind(conditionalMiddleware)); + }).not.toThrow(); + }); + + it('should handle undefined request path gracefully', async () => { + const conditionalMiddleware = unless(correlationMiddleware, ['/health']); + + // Create a mock request with no path + const mockReq = { url: undefined }; + const mockRes = { setHeader: jest.fn() }; + const mockNext = jest.fn(); + + // Should not throw an error + expect(() => { + conditionalMiddleware.use(mockReq as any, mockRes as any, mockNext); + }).not.toThrow(); + + expect(mockNext).toHaveBeenCalled(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 693741f..f785f24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "@nestjs/swagger": "^11.2.5", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", + "@types/micromatch": "^4.0.10", "@types/passport-google-oauth20": "^2.0.16", "@types/pdfkit": "^0.14.0", "bcryptjs": "^3.0.2", @@ -55,6 +56,7 @@ "google-auth-library": "^9.15.1", "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", + "micromatch": "^4.0.8", "nodemailer": "^7.0.12", "oauth2client": "^1.0.0", "passport": "^0.7.0", @@ -5300,6 +5302,12 @@ "@types/node": "*" } }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5466,6 +5474,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/micromatch": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", + "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -7306,7 +7323,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -9850,7 +9866,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -11364,7 +11379,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -13388,7 +13402,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -14778,7 +14791,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -17337,7 +17349,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" From 08e2117bbc0163ea6f46414dda08ce6444a285c6 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Thu, 26 Mar 2026 16:55:20 +0100 Subject: [PATCH 2/2] fix fix --- backend/src/common/middleware/utils/conditional.middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/common/middleware/utils/conditional.middleware.ts b/backend/src/common/middleware/utils/conditional.middleware.ts index 35448f0..9016c0d 100644 --- a/backend/src/common/middleware/utils/conditional.middleware.ts +++ b/backend/src/common/middleware/utils/conditional.middleware.ts @@ -5,7 +5,7 @@ import * as micromatch from 'micromatch'; export type RoutePattern = string | RegExp | (string | RegExp)[]; /** - * Checks if the request path matches any of the provided patterns + * Checks if the request path matches any of the provided patternsss */ function matchesPath(path: string, patterns: RoutePattern): boolean { if (!patterns || (Array.isArray(patterns) && patterns.length === 0)) {