diff --git a/backend/src/index.ts b/backend/src/index.ts index f7a1814c..abc31d98 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,3 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { randomUUID } from 'node:crypto'; import express, { Request, Response, NextFunction } from 'express'; import * as Sentry from '@sentry/node'; import { nodeProfilingIntegration } from '@sentry/profiling-node'; @@ -48,6 +46,8 @@ import { messageQueue } from './services/queue.js'; import { registerDefaultProcessors } from './services/queue-producers.js'; import { slaTrackingMiddleware } from './middleware/slaTracking.js'; import { requestIdMiddleware, REQUEST_ID_HEADER } from './middleware/requestId.js'; +import { traceMiddleware } from './middleware/trace.js'; +import { cacheControlNoStore } from './middleware/cache-control.js'; import { validateEnv, config as getConfig } from './config/env.js'; import { flagsRouter } from './routes/flags.js'; import { rateLimitAnalyticsRouter } from './routes/rate-limit-analytics.js'; @@ -118,7 +118,7 @@ if (env.IP_ALLOWLIST_ENABLED || env.IP_ALLOWLIST) { console.log(`[IP Allowlist] Enabled with ${allowedIps.length} IP(s)`); } -const traceStorage = new AsyncLocalStorage(); +import { traceStorage } from './middleware/trace.js'; const originalConsole = { log: console.log, @@ -197,32 +197,10 @@ app.use( ); app.use(requestIdMiddleware); - -app.use((req: Request, res: Response, next: NextFunction) => { - const traceId = (req.headers['x-trace-id'] as string) || randomUUID(); - res.setHeader('X-Trace-Id', traceId); - - traceStorage.run(traceId, () => { - console.log(`${req.method} ${req.url} [RequestID: ${req.requestId}] - Started`); - - res.on('finish', () => { - console.log(`${req.method} ${req.url} [RequestID: ${req.requestId}] - Finished with status ${res.statusCode}`); - }); - - next(); - }); -}); - +app.use(traceMiddleware); app.use(slaTrackingMiddleware); app.use(sessionMiddleware); - -app.use((req: Request, res: Response, next: NextFunction) => { - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.setHeader('Cache-Control', 'no-store'); - } - res.setHeader('Vary', 'Accept-Encoding'); - next(); -}); +app.use(cacheControlNoStore); app.use(healthRouter); diff --git a/backend/src/middleware/__tests__/cache-control.test.ts b/backend/src/middleware/__tests__/cache-control.test.ts new file mode 100644 index 00000000..513b7304 --- /dev/null +++ b/backend/src/middleware/__tests__/cache-control.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { cacheControlNoStore, CACHE_NOSTORE_HEADER, VARY_HEADER } from '../cache-control.js'; + +function makeReq(overrides: Partial = {}): Request { + return { method: 'GET', ...overrides } as unknown as Request; +} + +function makeRes(): { res: Response; headers: Record } { + const headers: Record = {}; + const res = { + setHeader: vi.fn((name: string, value: string) => { headers[name] = value; }), + } as unknown as Response; + return { res, headers }; +} + +describe('cacheControlNoStore', () => { + it('sets Cache-Control: no-store for POST requests', () => { + const req = makeReq({ method: 'POST' }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store'); + expect(next).toHaveBeenCalledOnce(); + }); + + it('sets Cache-Control: no-store for PUT requests', () => { + const req = makeReq({ method: 'PUT' }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store'); + }); + + it('sets Cache-Control: no-store for DELETE requests', () => { + const req = makeReq({ method: 'DELETE' }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store'); + }); + + it('does NOT set Cache-Control: no-store for GET requests', () => { + const req = makeReq({ method: 'GET' }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(headers[CACHE_NOSTORE_HEADER]).toBeUndefined(); + }); + + it('does NOT set Cache-Control: no-store for HEAD requests', () => { + const req = makeReq({ method: 'HEAD' }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(headers[CACHE_NOSTORE_HEADER]).toBeUndefined(); + }); + + it('always sets Vary: Accept-Encoding header', () => { + const req = makeReq({ method: 'GET' }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(headers[VARY_HEADER]).toBe('Accept-Encoding'); + }); + + it('sets both headers for mutations', () => { + const req = makeReq({ method: 'PATCH' }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(headers[CACHE_NOSTORE_HEADER]).toBe('no-store'); + expect(headers[VARY_HEADER]).toBe('Accept-Encoding'); + }); + + it('calls next()', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + cacheControlNoStore(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/src/middleware/__tests__/compose.test.ts b/backend/src/middleware/__tests__/compose.test.ts new file mode 100644 index 00000000..e65f1d28 --- /dev/null +++ b/backend/src/middleware/__tests__/compose.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { composeMiddleware, createMiddlewareChain } from '../compose.js'; + +function makeReq(overrides: Partial = {}): Request { + return { method: 'GET', headers: {}, ...overrides } as unknown as Request; +} + +function makeRes(): Response { + return { status: vi.fn().mockReturnThis(), json: vi.fn(), setHeader: vi.fn() } as unknown as Response; +} + +describe('composeMiddleware', () => { + it('executes middleware in order', () => { + const order: number[] = []; + const mw1 = (_req: Request, _res: Response, next: any) => { order.push(1); next(); }; + const mw2 = (_req: Request, _res: Response, next: any) => { order.push(2); next(); }; + const mw3 = (_req: Request, _res: Response, next: any) => { order.push(3); next(); }; + + const composed = composeMiddleware(mw1, mw2, mw3); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + composed(req, res, next); + + expect(order).toEqual([1, 2, 3]); + expect(next).toHaveBeenCalledOnce(); + }); + + it('handles async middleware', async () => { + const order: number[] = []; + const mw1 = async (_req: Request, _res: Response, next: any) => { order.push(1); next(); }; + const mw2 = async (_req: Request, _res: Response, next: any) => { order.push(2); next(); }; + + const composed = composeMiddleware(mw1, mw2); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + composed(req, res, next); + + await new Promise(process.nextTick); + expect(order).toEqual([1, 2]); + }); + + it('passes errors to the outer next() callback', () => { + const testError = new Error('middleware error'); + const mw1 = (_req: Request, _res: Response, next: any) => { next(testError); }; + + const composed = composeMiddleware(mw1); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + composed(req, res, next); + + expect(next).toHaveBeenCalledWith(testError); + }); + + it('catches thrown errors and forwards to next()', () => { + const mw1 = (_req: Request, _res: Response, _next: any) => { throw new Error('thrown error'); }; + + const composed = composeMiddleware(mw1); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + composed(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('catches async rejections and forwards to next()', async () => { + const mw1 = async (_req: Request, _res: Response, _next: any) => { + throw new Error('async rejection'); + }; + + const composed = composeMiddleware(mw1); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + composed(req, res, next); + + await new Promise(process.nextTick); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('calls next() when no middleware is provided', () => { + const composed = composeMiddleware(); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + composed(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + }); +}); + +describe('createMiddlewareChain', () => { + it('builds and executes a chain', () => { + const order: number[] = []; + const chain = createMiddlewareChain() + .use( + (_req: Request, _res: Response, next: any) => { order.push(1); next(); }, + (_req: Request, _res: Response, next: any) => { order.push(2); next(); }, + ) + .use( + (_req: Request, _res: Response, next: any) => { order.push(3); next(); }, + ); + + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + chain.execute(req, res, next); + + expect(order).toEqual([1, 2, 3]); + expect(next).toHaveBeenCalledOnce(); + }); + + it('supports chaining .use() multiple times on returned chain', () => { + const order: number[] = []; + const chain = createMiddlewareChain() + .use((_req, _res, next) => { order.push(1); next(); }); + + const chain2 = chain.use((_req, _res, next) => { order.push(2); next(); }); + + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + chain2.execute(req, res, next); + + expect(order).toEqual([1, 2]); + }); +}); diff --git a/backend/src/middleware/__tests__/deprecation.test.ts b/backend/src/middleware/__tests__/deprecation.test.ts index c7944f23..d771da81 100644 --- a/backend/src/middleware/__tests__/deprecation.test.ts +++ b/backend/src/middleware/__tests__/deprecation.test.ts @@ -1,128 +1,125 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Request, Response } from 'express'; -import { deprecationMiddleware } from '../deprecation.js'; -// ─── Helpers ────────────────────────────────────────────────────────────────── +const mockLoggerWarn = vi.fn(); +vi.mock('../logger.js', () => ({ + logger: { warn: mockLoggerWarn, info: vi.fn(), error: vi.fn() }, +})); + +import { deprecationMiddleware } from '../deprecation.js'; function makeReq(overrides: Partial = {}): Request { - return { - method: 'GET', - originalUrl: '/api/v1/test', - ip: '127.0.0.1', - headers: {}, - ...overrides, - } as unknown as Request; + return { + method: 'GET', + originalUrl: '/api/v1/test', + ip: '127.0.0.1', + headers: {}, + ...overrides, + } as unknown as Request; } function makeRes(): { - res: Response; - headers: Record; + res: Response; + headers: Record; } { - const headers: Record = {}; - - const res = { - setHeader: vi.fn((name: string, value: string | string[] | number) => { - headers[name] = value; - }), - getHeader: vi.fn((name: string) => { - return headers[name]; - }), - } as unknown as Response; - - return { res, headers }; + const headers: Record = {}; + + const res = { + setHeader: vi.fn((name: string, value: string | string[] | number) => { + headers[name] = value; + }), + getHeader: vi.fn((name: string) => { + return headers[name]; + }), + } as unknown as Response; + + return { res, headers }; } -// ─── Tests ──────────────────────────────────────────────────────────────────── - describe('deprecationMiddleware()', () => { - let next: ReturnType; - - beforeEach(() => { - next = vi.fn(); - // Mock console.warn to avoid cluttering test output - vi.spyOn(console, 'warn').mockImplementation(() => { }); - }); - - it('sets Deprecation header', () => { - const req = makeReq(); - const { res, headers } = makeRes(); - const deprecationDate = '2023-12-31'; - const mw = deprecationMiddleware({ deprecationDate }); - - mw(req, res, next); - - expect(headers['Deprecation']).toBe(new Date(deprecationDate).toUTCString()); - expect(next).toHaveBeenCalledOnce(); + let next: ReturnType; + + beforeEach(() => { + next = vi.fn(); + vi.clearAllMocks(); + }); + + it('sets Deprecation header', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + const deprecationDate = '2023-12-31'; + const mw = deprecationMiddleware({ deprecationDate }); + + mw(req, res, next); + + expect(headers['Deprecation']).toBe(new Date(deprecationDate).toUTCString()); + expect(next).toHaveBeenCalledOnce(); + }); + + it('sets Sunset header when provided', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + const deprecationDate = '2023-10-01'; + const sunsetDate = '2024-03-31'; + const mw = deprecationMiddleware({ deprecationDate, sunsetDate }); + + mw(req, res, next); + + expect(headers['Deprecation']).toBe(new Date(deprecationDate).toUTCString()); + expect(headers['Sunset']).toBe(new Date(sunsetDate).toUTCString()); + }); + + it('sets Link header for successor-version', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + const alternativeUrl = 'https://api.example.com/v2'; + const mw = deprecationMiddleware({ + deprecationDate: '2023-01-01', + alternativeUrl, }); - it('sets Sunset header when provided', () => { - const req = makeReq(); - const { res, headers } = makeRes(); - const deprecationDate = '2023-10-01'; - const sunsetDate = '2024-03-31'; - const mw = deprecationMiddleware({ deprecationDate, sunsetDate }); + mw(req, res, next); - mw(req, res, next); - - expect(headers['Deprecation']).toBe(new Date(deprecationDate).toUTCString()); - expect(headers['Sunset']).toBe(new Date(sunsetDate).toUTCString()); - }); + expect(headers['Link']).toBe(`<${alternativeUrl}>; rel="successor-version"`); + }); - it('sets Link header for successor-version', () => { - const req = makeReq(); - const { res, headers } = makeRes(); - const alternativeUrl = 'https://api.example.com/v2'; - const mw = deprecationMiddleware({ - deprecationDate: '2023-01-01', - alternativeUrl - }); + it('appends to existing Link header', () => { + const req = makeReq(); + const { res, headers } = makeRes(); - mw(req, res, next); + const existingLink = '; rel="help"'; + res.setHeader('Link', existingLink); - expect(headers['Link']).toBe(`<${alternativeUrl}>; rel="successor-version"`); + const alternativeUrl = 'https://api.example.com/v2'; + const mw = deprecationMiddleware({ + deprecationDate: '2023-01-01', + alternativeUrl, }); - it('appends to existing Link header', () => { - const req = makeReq(); - const { res, headers } = makeRes(); + mw(req, res, next); - // Set existing Link header - const existingLink = '; rel="help"'; - res.setHeader('Link', existingLink); + const linkHeader = headers['Link']; + expect(Array.isArray(linkHeader)).toBe(true); + expect(linkHeader).toContain(existingLink); + expect(linkHeader).toContain(`<${alternativeUrl}>; rel="successor-version"`); + }); - const alternativeUrl = 'https://api.example.com/v2'; - const mw = deprecationMiddleware({ - deprecationDate: '2023-01-01', - alternativeUrl - }); + it('logs a warning with request details using structured logger', () => { + const req = makeReq({ method: 'POST', originalUrl: '/api/v1/old-endpoint' }); + const { res } = makeRes(); + const deprecationDate = '2023-06-01'; + const mw = deprecationMiddleware({ deprecationDate }); - mw(req, res, next); + mw(req, res, next); - const linkHeader = headers['Link']; - expect(Array.isArray(linkHeader)).toBe(true); - expect(linkHeader).toContain(existingLink); - expect(linkHeader).toContain(`<${alternativeUrl}>; rel="successor-version"`); - }); - - it('logs a warning with request details', () => { - const req = makeReq({ method: 'POST', originalUrl: '/api/v1/old-endpoint' }); - const { res } = makeRes(); - const deprecationDate = '2023-06-01'; - const mw = deprecationMiddleware({ deprecationDate }); - - mw(req, res, next); - - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('[DEPRECATION WARNING]') - ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('POST') - ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('/api/v1/old-endpoint') - ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining(deprecationDate) - ); - }); + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '127.0.0.1', + method: 'POST', + url: '/api/v1/old-endpoint', + deprecationDate: '2023-06-01', + }), + 'Deprecated endpoint accessed', + ); + }); }); diff --git a/backend/src/middleware/__tests__/errorHandler.test.ts b/backend/src/middleware/__tests__/errorHandler.test.ts new file mode 100644 index 00000000..5a2dfcbe --- /dev/null +++ b/backend/src/middleware/__tests__/errorHandler.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { AppError, asyncHandler, notFoundHandler, errorHandler } from '../errorHandler.js'; + +function makeReq(overrides: Partial = {}): Request { + return { method: 'GET', originalUrl: '/test', ...overrides } as unknown as Request; +} + +function makeRes(): { res: Response; statusCode: number; body: unknown } { + let statusCode = 200; + let body: unknown = undefined; + const res = { + status: vi.fn(function (code: number) { statusCode = code; return res; }), + json: vi.fn(function (data: unknown) { body = data; return res; }), + } as unknown as Response; + return { res, get statusCode() { return statusCode; }, get body() { return body; } }; +} + +describe('AppError', () => { + it('creates an error with statusCode, message, and code', () => { + const err = new AppError(404, 'Not found', 'NOT_FOUND', { resource: 'user' }); + expect(err.statusCode).toBe(404); + expect(err.message).toBe('Not found'); + expect(err.code).toBe('NOT_FOUND'); + expect(err.details).toEqual({ resource: 'user' }); + expect(err.name).toBe('AppError'); + }); + + it('defaults code to INTERNAL_SERVER_ERROR', () => { + const err = new AppError(500, 'Server error'); + expect(err.code).toBe('INTERNAL_SERVER_ERROR'); + }); + + it('defaults details to undefined', () => { + const err = new AppError(400, 'Bad request'); + expect(err.details).toBeUndefined(); + }); +}); + +describe('asyncHandler', () => { + it('calls next with resolved value on success', async () => { + const handler = asyncHandler(async (_req: Request, _res: Response) => 'done'); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + handler(req, res, next); + + await new Promise(process.nextTick); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next with error when handler rejects', async () => { + const testError = new Error('async error'); + const handler = asyncHandler(async (_req: Request, _res: Response) => { throw testError; }); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + handler(req, res, next); + + await new Promise(process.nextTick); + expect(next).toHaveBeenCalledWith(testError); + }); +}); + +describe('notFoundHandler', () => { + it('creates a 404 AppError', () => { + const req = makeReq({ method: 'POST', originalUrl: '/api/unknown' }); + const { res } = makeRes(); + const next = vi.fn(); + + notFoundHandler(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const err = next.mock.calls[0][0] as AppError; + expect(err.statusCode).toBe(404); + expect(err.code).toBe('NOT_FOUND'); + expect(err.message).toContain('/api/unknown'); + }); +}); + +describe('errorHandler', () => { + let env: string | undefined; + + beforeEach(() => { + env = process.env.NODE_ENV; + }); + + afterEach(() => { + process.env.NODE_ENV = env; + }); + + it('responds with structured error for AppError', () => { + const err = new AppError(403, 'Forbidden', 'FORBIDDEN'); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: { code: 'FORBIDDEN', message: 'Forbidden', status: 403 }, + }); + }); + + it('responds with 500 for unknown errors', () => { + const err = new Error('unexpected'); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + }); + + it('includes stack trace in non-production', () => { + process.env.NODE_ENV = 'development'; + const err = new Error('dev error'); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + errorHandler(err, req, res, next); + + const callArg = (res.json as ReturnType).mock.calls[0][0]; + expect(callArg.error.stack).toBeDefined(); + }); + + it('hides stack trace in production', () => { + process.env.NODE_ENV = 'production'; + const err = new Error('prod error'); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + errorHandler(err, req, res, next); + + const callArg = (res.json as ReturnType).mock.calls[0][0]; + expect(callArg.error.stack).toBeUndefined(); + }); + + it('includes details from AppError', () => { + const err = new AppError(422, 'Validation failed', 'VALIDATION_ERROR', { field: 'email' }); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + errorHandler(err, req, res, next); + + const callArg = (res.json as ReturnType).mock.calls[0][0]; + expect(callArg.error.details).toEqual({ field: 'email' }); + }); +}); diff --git a/backend/src/middleware/__tests__/idempotency.test.ts b/backend/src/middleware/__tests__/idempotency.test.ts new file mode 100644 index 00000000..3914981c --- /dev/null +++ b/backend/src/middleware/__tests__/idempotency.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { idempotency, clearIdempotencyCache } from '../idempotency.js'; + +function makeReq(overrides: Partial = {}): Request { + return { + method: 'POST', + originalUrl: '/api/v1/payments', + headers: {}, + ...overrides, + } as unknown as Request; +} + +function makeRes(): { + res: Response; + statusCode: number; + sentBody: unknown; + jsonCalled: boolean; +} { + let statusCode = 200; + let sentBody: unknown = undefined; + let jsonCalled = false; + + const res = { + statusCode, + status: vi.fn(function (code: number) { + statusCode = code; + return res; + }), + json: vi.fn(function (body: unknown) { + jsonCalled = true; + sentBody = body; + return res; + }), + get statusCode() { return statusCode; }, + set statusCode(v: number) { statusCode = v; }, + } as unknown as Response; + + return { + res, + get statusCode() { return statusCode; }, + get sentBody() { return sentBody; }, + get jsonCalled() { return jsonCalled; }, + }; +} + +describe('idempotency middleware', () => { + beforeEach(() => { + clearIdempotencyCache(); + }); + + it('calls next() when no x-idempotency-key header is present', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + const mw = idempotency(); + mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + }); + + it('intercepts res.json to cache the response', () => { + const req = makeReq({ headers: { 'x-idempotency-key': 'key-123' } }); + const { res } = makeRes(); + const next = vi.fn(); + + const mw = idempotency(); + mw(req, res, next); + + const originalJson = res.json; + expect(res.json).not.toBe(originalJson); + expect(next).toHaveBeenCalledOnce(); + }); + + it('returns cached response on subsequent identical request', () => { + const req1 = makeReq({ headers: { 'x-idempotency-key': 'key-456' } }); + const { res: res1 } = makeRes(); + const next1 = vi.fn(); + + const mw = idempotency(); + mw(req1, res1, next1); + res1.statusCode = 201; + (res1.json as ReturnType)({ id: 'payment_1', status: 'succeeded' }); + + const req2 = makeReq({ headers: { 'x-idempotency-key': 'key-456' } }); + const { res: res2 } = makeRes(); + const next2 = vi.fn(); + + mw(req2, res2, next2); + + expect(res2.status).toHaveBeenCalledWith(201); + expect(res2.json).toHaveBeenCalledWith({ id: 'payment_1', status: 'succeeded' }); + expect(next2).not.toHaveBeenCalled(); + }); + + it('uses different caches for different idempotency keys', () => { + const mw = idempotency(); + + const req1 = makeReq({ headers: { 'x-idempotency-key': 'key-a' } }); + const { res: res1 } = makeRes(); + mw(req1, res1, vi.fn()); + res1.statusCode = 200; + (res1.json as ReturnType)({ result: 'a' }); + + const req2 = makeReq({ headers: { 'x-idempotency-key': 'key-b' } }); + const { res: res2 } = makeRes(); + const next2 = vi.fn(); + + mw(req2, res2, next2); + + expect(next2).toHaveBeenCalledOnce(); + const originalJson = res2.json; + expect(res2.json).not.toBe(originalJson); + }); + + it('uses different caches for different routes with same key', () => { + const mw = idempotency(); + + const req1 = makeReq({ originalUrl: '/api/v1/payments', headers: { 'x-idempotency-key': 'key-same' } }); + const { res: res1 } = makeRes(); + mw(req1, res1, vi.fn()); + res1.statusCode = 200; + (res1.json as ReturnType)({ route: 'payments' }); + + const req2 = makeReq({ originalUrl: '/api/v1/refunds', headers: { 'x-idempotency-key': 'key-same' } }); + const { res: res2 } = makeRes(); + const next2 = vi.fn(); + + mw(req2, res2, next2); + + expect(next2).toHaveBeenCalledOnce(); + }); + + it('expires cached entries after TTL', async () => { + const mw = idempotency(10); + + const req1 = makeReq({ headers: { 'x-idempotency-key': 'key-expire' } }); + const { res: res1 } = makeRes(); + mw(req1, res1, vi.fn()); + res1.statusCode = 200; + (res1.json as ReturnType)({ data: 'fresh' }); + + await new Promise((r) => setTimeout(r, 20)); + + const req2 = makeReq({ headers: { 'x-idempotency-key': 'key-expire' } }); + const { res: res2 } = makeRes(); + const next2 = vi.fn(); + + mw(req2, res2, next2); + + expect(next2).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/src/middleware/__tests__/requestId.test.ts b/backend/src/middleware/__tests__/requestId.test.ts new file mode 100644 index 00000000..6af81b7a --- /dev/null +++ b/backend/src/middleware/__tests__/requestId.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { requestIdMiddleware, REQUEST_ID_HEADER } from '../requestId.js'; + +function makeReq(overrides: Partial = {}): Request { + return { method: 'GET', headers: {}, ...overrides } as unknown as Request; +} + +function makeRes(): { res: Response; headers: Record } { + const headers: Record = {}; + const res = { + setHeader: vi.fn((name: string, value: string) => { headers[name] = value; }), + } as unknown as Response; + return { res, headers }; +} + +describe('requestIdMiddleware', () => { + it('generates a UUID when no x-request-id header is present', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + requestIdMiddleware(req, res, next); + + expect(req.requestId).toMatch(/^[0-9a-f-]{36}$/); + expect(res.setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, req.requestId); + expect(next).toHaveBeenCalledOnce(); + }); + + it('uses existing x-request-id header when present', () => { + const existingId = 'existing-id-12345'; + const req = makeReq({ headers: { 'x-request-id': existingId } }); + const { res } = makeRes(); + const next = vi.fn(); + + requestIdMiddleware(req, res, next); + + expect(req.requestId).toBe(existingId); + expect(res.setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, existingId); + }); + + it('sets the X-Request-Id response header', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + requestIdMiddleware(req, res, next); + + expect(res.setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, expect.any(String)); + }); + + it('calls next()', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + requestIdMiddleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/src/middleware/__tests__/requireFlag.test.ts b/backend/src/middleware/__tests__/requireFlag.test.ts new file mode 100644 index 00000000..09e7a44a --- /dev/null +++ b/backend/src/middleware/__tests__/requireFlag.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; + +vi.mock('../config/featureFlags.js', () => ({ + featureFlags: { evaluate: vi.fn() }, +})); + +import { requireFlag } from '../requireFlag.js'; +import { featureFlags } from '../config/featureFlags.js'; + +const mockEvaluate = vi.mocked(featureFlags.evaluate); + +function makeReq(overrides: Partial = {}): Request { + return { + method: 'GET', + ip: '192.168.1.1', + headers: {}, + ...overrides, + } as unknown as Request; +} + +function makeRes(): Response { + return { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } as unknown as Response; +} + +describe('requireFlag', () => { + beforeEach(() => { + mockEvaluate.mockReset(); + }); + + it('calls next() when feature flag is enabled', () => { + mockEvaluate.mockReturnValue(true); + const req = makeReq({ headers: { 'x-user-id': 'user_1' } }); + const res = makeRes(); + const next = vi.fn(); + + const mw = requireFlag('bulk-verification' as any); + mw(req, res, next); + + expect(mockEvaluate).toHaveBeenCalledWith('bulk-verification', 'user_1'); + expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(); + }); + + it('passes AppError to next() when feature flag is disabled', () => { + mockEvaluate.mockReturnValue(false); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn(); + + const mw = requireFlag('premium-feature' as any); + mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const err = next.mock.calls[0][0] as any; + expect(err.statusCode).toBe(403); + expect(err.code).toBe('FEATURE_DISABLED'); + expect(err.message).toContain('premium-feature'); + }); + + it('resolves identifier from x-user-id header first', () => { + mockEvaluate.mockReturnValue(true); + const req = makeReq({ + headers: { + 'x-user-id': 'user_456', + authorization: 'Bearer token', + 'x-api-key': 'key_789', + }, + }); + const res = makeRes(); + const next = vi.fn(); + + const mw = requireFlag('test-flag' as any); + mw(req, res, next); + + expect(mockEvaluate).toHaveBeenCalledWith('test-flag', 'user_456'); + }); + + it('falls back to authorization header when x-user-id is absent', () => { + mockEvaluate.mockReturnValue(true); + const req = makeReq({ headers: { authorization: 'Bearer token123' } }); + const res = makeRes(); + const next = vi.fn(); + + const mw = requireFlag('test-flag' as any); + mw(req, res, next); + + expect(mockEvaluate).toHaveBeenCalledWith('test-flag', 'Bearer token123'); + }); + + it('falls back to x-api-key header when neither user-id nor auth present', () => { + mockEvaluate.mockReturnValue(true); + const req = makeReq({ headers: { 'x-api-key': 'api_key_abc' } }); + const res = makeRes(); + const next = vi.fn(); + + const mw = requireFlag('test-flag' as any); + mw(req, res, next); + + expect(mockEvaluate).toHaveBeenCalledWith('test-flag', 'api_key_abc'); + }); + + it('falls back to req.ip when no identifier headers present', () => { + mockEvaluate.mockReturnValue(true); + const req = makeReq({ ip: '10.0.0.1' }); + const res = makeRes(); + const next = vi.fn(); + + const mw = requireFlag('test-flag' as any); + mw(req, res, next); + + expect(mockEvaluate).toHaveBeenCalledWith('test-flag', '10.0.0.1'); + }); + + it('falls back to anonymous when req.ip is undefined', () => { + mockEvaluate.mockReturnValue(true); + const req = makeReq({ ip: undefined }); + const res = makeRes(); + const next = vi.fn(); + + const mw = requireFlag('test-flag' as any); + mw(req, res, next); + + expect(mockEvaluate).toHaveBeenCalledWith('test-flag', 'anonymous'); + }); +}); diff --git a/backend/src/middleware/__tests__/session.test.ts b/backend/src/middleware/__tests__/session.test.ts new file mode 100644 index 00000000..449d9bd3 --- /dev/null +++ b/backend/src/middleware/__tests__/session.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; + +vi.mock('../services/session.js', () => ({ + updateSessionActivity: vi.fn(), + getSession: vi.fn(), + checkSessionAnomaly: vi.fn(), +})); + +vi.mock('../logger.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +import { sessionMiddleware } from '../session.js'; +import { getSession, updateSessionActivity, checkSessionAnomaly } from '../services/session.js'; + +function makeReq(overrides: Partial = {}): Request { + return { + method: 'GET', + headers: {}, + socket: { remoteAddress: '127.0.0.1' } as any, + ...overrides, + } as unknown as Request; +} + +function makeRes(): { res: Response; headers: Record } { + const headers: Record = {}; + const res = { + setHeader: vi.fn((name: string, value: string) => { headers[name] = value; }), + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; + return { res, headers }; +} + +describe('sessionMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls next() when no session header is present', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + sessionMiddleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(getSession).not.toHaveBeenCalled(); + }); + + it('calls next() when session id is present but session not found', () => { + vi.mocked(getSession).mockReturnValue(null); + const req = makeReq({ headers: { 'x-session-id': 'sess_123' } }); + const { res } = makeRes(); + const next = vi.fn(); + + sessionMiddleware(req, res, next); + + expect(getSession).toHaveBeenCalledWith('sess_123'); + expect(next).toHaveBeenCalledOnce(); + }); + + it('returns 401 with AppError when session is terminated', () => { + vi.mocked(getSession).mockReturnValue({ status: 'terminated', userId: 'user_1' } as any); + const req = makeReq({ headers: { 'x-session-id': 'sess_terminated' } }); + const { res } = makeRes(); + const next = vi.fn(); + + sessionMiddleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + const err = next.mock.calls[0][0] as any; + expect(err.statusCode).toBe(401); + expect(err.code).toBe('SESSION_TERMINATED'); + }); + + it('updates session activity for active sessions', () => { + vi.mocked(getSession).mockReturnValue({ status: 'active', userId: 'user_1' } as any); + vi.mocked(checkSessionAnomaly).mockReturnValue(null); + const req = makeReq({ headers: { 'x-session-id': 'sess_active' } }); + const { res } = makeRes(); + const next = vi.fn(); + + sessionMiddleware(req, res, next); + + expect(updateSessionActivity).toHaveBeenCalledWith('sess_active', '127.0.0.1'); + expect(next).toHaveBeenCalledOnce(); + }); + + it('sets warning header when anomaly detected', () => { + vi.mocked(getSession).mockReturnValue({ status: 'active', userId: 'user_1' } as any); + vi.mocked(checkSessionAnomaly).mockReturnValue('IP mismatch'); + const req = makeReq({ headers: { 'x-session-id': 'sess_anomaly' } }); + const { res, headers } = makeRes(); + const next = vi.fn(); + + sessionMiddleware(req, res, next); + + expect(headers['X-Session-Warning']).toBe('IP mismatch'); + expect(next).toHaveBeenCalledOnce(); + }); + + it('uses x-forwarded-for header for IP when available', () => { + vi.mocked(getSession).mockReturnValue({ status: 'active', userId: 'user_1' } as any); + vi.mocked(checkSessionAnomaly).mockReturnValue(null); + const req = makeReq({ + headers: { 'x-session-id': 'sess_proxy', 'x-forwarded-for': '203.0.113.1' }, + }); + const { res } = makeRes(); + const next = vi.fn(); + + sessionMiddleware(req, res, next); + + expect(updateSessionActivity).toHaveBeenCalledWith('sess_proxy', '203.0.113.1'); + }); +}); diff --git a/backend/src/middleware/__tests__/slaTracking.test.ts b/backend/src/middleware/__tests__/slaTracking.test.ts new file mode 100644 index 00000000..c357b226 --- /dev/null +++ b/backend/src/middleware/__tests__/slaTracking.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; + +vi.mock('../services/sla.js', () => ({ + slaTracker: { trackRequest: vi.fn() }, +})); + +import { slaTrackingMiddleware } from '../slaTracking.js'; +import { slaTracker } from '../services/sla.js'; + +const mockTrackRequest = vi.mocked(slaTracker.trackRequest); + +function makeReq(overrides: Partial = {}): Request { + return { + method: 'GET', + baseUrl: '/api/v1', + path: '/test', + ...overrides, + } as unknown as Request; +} + +function makeRes(): { res: Response; finishHandlers: Array<() => void> } { + const finishHandlers: Array<() => void> = []; + const res = { + statusCode: 200, + on: vi.fn((event: string, handler: () => void) => { if (event === 'finish') finishHandlers.push(handler); }), + } as unknown as Response; + return { res, finishHandlers }; +} + +describe('slaTrackingMiddleware', () => { + beforeEach(() => { + mockTrackRequest.mockReset(); + }); + + it('registers a finish handler on the response', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + slaTrackingMiddleware(req, res, next); + + expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function)); + expect(next).toHaveBeenCalledOnce(); + }); + + it('calls slaTracker.trackRequest on finish', () => { + const req = makeReq(); + const { res, finishHandlers } = makeRes(); + const next = vi.fn(); + + slaTrackingMiddleware(req, res, next); + finishHandlers[0](); + + expect(mockTrackRequest).toHaveBeenCalledOnce(); + const [endpoint, _responseTimeMs, statusCode, _date] = mockTrackRequest.mock.calls[0]; + expect(endpoint).toBe('GET /api/v1/test'); + expect(statusCode).toBe(200); + }); + + it('tracks the correct endpoint format with query params', () => { + const req = makeReq({ path: '/users?page=1' }); + const { res, finishHandlers } = makeRes(); + const next = vi.fn(); + + slaTrackingMiddleware(req, res, next); + finishHandlers[0](); + + // path should include the query string part from req.path + expect(mockTrackRequest.mock.calls[0][0]).toContain('/users'); + }); + + it('calls next before the request finishes', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + slaTrackingMiddleware(req, res, next); + + expect(next).toHaveBeenCalledBefore + ? expect(next).toHaveBeenCalledBefore(mockTrackRequest) + : expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/src/middleware/__tests__/trace.test.ts b/backend/src/middleware/__tests__/trace.test.ts new file mode 100644 index 00000000..cd2d9ef7 --- /dev/null +++ b/backend/src/middleware/__tests__/trace.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { traceMiddleware, getTraceId, traceStorage, TRACE_ID_HEADER } from '../trace.js'; + +function makeReq(overrides: Partial = {}): Request { + return { method: 'GET', headers: {}, ...overrides } as unknown as Request; +} + +function makeRes(): { res: Response; headers: Record } { + const headers: Record = {}; + const res = { + setHeader: vi.fn((name: string, value: string) => { headers[name] = value; }), + } as unknown as Response; + return { res, headers }; +} + +describe('traceMiddleware', () => { + it('generates a trace ID when no x-trace-id header is present', () => { + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + traceMiddleware(req, res, next); + + expect(res.setHeader).toHaveBeenCalledWith('X-Trace-Id', expect.any(String)); + expect(next).toHaveBeenCalledOnce(); + }); + + it('uses existing x-trace-id header when present', () => { + const existingTraceId = 'trace-abc-123'; + const req = makeReq({ headers: { 'x-trace-id': existingTraceId } }); + const { res } = makeRes(); + const next = vi.fn(); + + traceMiddleware(req, res, next); + + expect(res.setHeader).toHaveBeenCalledWith('X-Trace-Id', existingTraceId); + }); + + it('runs next() within AsyncLocalStorage context', () => { + const req = makeReq({ headers: { 'x-trace-id': 'test-trace' } }); + const { res } = makeRes(); + let capturedTraceId: string | undefined; + + const next = vi.fn(() => { + capturedTraceId = getTraceId(); + }); + + traceMiddleware(req, res, next); + + expect(capturedTraceId).toBe('test-trace'); + }); + + it('sets X-Trace-Id response header', () => { + const req = makeReq(); + const { res, headers } = makeRes(); + const next = vi.fn(); + + traceMiddleware(req, res, next); + + expect(headers['X-Trace-Id']).toBeDefined(); + }); + + it('getTraceId returns undefined outside of a trace context', () => { + const traceId = getTraceId(); + expect(traceId).toBeUndefined(); + }); +}); diff --git a/backend/src/middleware/__tests__/validate.test.ts b/backend/src/middleware/__tests__/validate.test.ts new file mode 100644 index 00000000..9b7af6c7 --- /dev/null +++ b/backend/src/middleware/__tests__/validate.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { validate } from '../validate.js'; + +function makeReq(body: unknown): Request { + return { body, method: 'POST' } as unknown as Request; +} + +function makeRes(): { res: Response; statusCode: number; body: unknown } { + let statusCode = 200; + let body: unknown = undefined; + const res = { + status: vi.fn(function (code: number) { statusCode = code; return res; }), + json: vi.fn(function (data: unknown) { body = data; return res; }), + } as unknown as Response; + return { res, get statusCode() { return statusCode; }, get body() { return body; } }; +} + +describe('validate middleware', () => { + const schema = z.object({ + name: z.string().min(1), + email: z.string().email(), + age: z.number().min(18), + }); + + it('calls next() when validation passes', () => { + const req = makeReq({ name: 'John', email: 'john@example.com', age: 25 }); + const { res } = makeRes(); + const next = vi.fn(); + + const mw = validate(schema); + mw(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(); + }); + + it('returns 400 with error details when validation fails', () => { + const req = makeReq({ name: '', email: 'invalid', age: 15 }); + const { res } = makeRes(); + const next = vi.fn(); + + const mw = validate(schema); + mw(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Validation failed', + errors: expect.arrayContaining([ + expect.objectContaining({ path: 'email' }), + expect.objectContaining({ path: 'age' }), + ]), + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('passes non-Zod errors to next()', () => { + const throwingSchema = { + parse: () => { throw new Error('Unexpected'); }, + } as unknown as z.ZodSchema; + + const req = makeReq({}); + const { res } = makeRes(); + const next = vi.fn(); + + const mw = validate(throwingSchema); + mw(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('returns 400 with empty errors array for ZodError with no issues', () => { + const emptySchema = z.object({}); + const req = makeReq({ unexpected: 'field' }); + const { res } = makeRes(); + const next = vi.fn(); + + const mw = validate(emptySchema); + mw(req, res, next); + + // empty object schema should pass with extra fields (Zod strips by default) + expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/src/middleware/cache-control.ts b/backend/src/middleware/cache-control.ts new file mode 100644 index 00000000..bd83dee0 --- /dev/null +++ b/backend/src/middleware/cache-control.ts @@ -0,0 +1,12 @@ +import type { Request, Response, NextFunction } from 'express'; + +export const CACHE_NOSTORE_HEADER = 'Cache-Control'; +export const VARY_HEADER = 'Vary'; + +export function cacheControlNoStore(req: Request, res: Response, next: NextFunction): void { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.setHeader(CACHE_NOSTORE_HEADER, 'no-store'); + } + res.setHeader(VARY_HEADER, 'Accept-Encoding'); + next(); +} diff --git a/backend/src/middleware/compose.ts b/backend/src/middleware/compose.ts new file mode 100644 index 00000000..f7d879ec --- /dev/null +++ b/backend/src/middleware/compose.ts @@ -0,0 +1,65 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; + +export type MiddlewareFunction = (req: Request, res: Response, next: NextFunction) => void | Promise; + +export interface MiddlewareChain { + use: (...fns: MiddlewareFunction[]) => MiddlewareChain; + execute: (req: Request, res: Response, next: NextFunction) => void; +} + +export function composeMiddleware(...middleware: MiddlewareFunction[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + let index = 0; + + const dispatch = (err?: unknown): void => { + if (err) { + next(err); + return; + } + + if (index >= middleware.length) { + next(); + return; + } + + const fn = middleware[index++]; + + try { + const result = fn(req, res, dispatch); + if (result instanceof Promise) { + result.catch(dispatch); + } + } catch (error) { + dispatch(error); + } + }; + + dispatch(); + }; +} + +export function createMiddlewareChain(): MiddlewareChain { + const stack: MiddlewareFunction[] = []; + + return { + use: (...fns: MiddlewareFunction[]) => { + stack.push(...fns); + return createMiddlewareChainFromStack(stack); + }, + execute: (req: Request, res: Response, next: NextFunction) => { + composeMiddleware(...stack)(req, res, next); + }, + }; +} + +function createMiddlewareChainFromStack(stack: MiddlewareFunction[]): MiddlewareChain { + return { + use: (...fns: MiddlewareFunction[]) => { + stack.push(...fns); + return createMiddlewareChainFromStack(stack); + }, + execute: (req: Request, res: Response, next: NextFunction) => { + composeMiddleware(...stack)(req, res, next); + }, + }; +} diff --git a/backend/src/middleware/deprecation.ts b/backend/src/middleware/deprecation.ts index cc4d7a28..f0cf5862 100644 --- a/backend/src/middleware/deprecation.ts +++ b/backend/src/middleware/deprecation.ts @@ -1,50 +1,24 @@ import { Request, Response, NextFunction } from 'express'; +import { logger } from './logger.js'; -/** - * Options for the deprecation middleware. - */ -interface DeprecationOptions { - /** - * The date when the endpoint was deprecated (ISO 8601 format, e.g., '2023-12-31'). - */ +export interface DeprecationOptions { deprecationDate: string; - /** - * The date when the endpoint will be removed (ISO 8601 format, e.g., '2024-06-30'). - */ sunsetDate?: string; - /** - * URL to the new endpoint or documentation. - */ alternativeUrl?: string; } -/** - * Middleware to add deprecation headers to a response. - * Follows the draft-ietf-httpapi-deprecation-header and draft-ietf-httpapi-sunset-header. - * - * @param options DeprecationOptions - * @returns Express Middleware - */ export const deprecationMiddleware = (options: DeprecationOptions) => { return (req: Request, res: Response, next: NextFunction) => { - // 1. Add Deprecation header - // The Deprecation header indicates that the resource is deprecated. - // It can also include the date when the deprecation started. res.setHeader('Deprecation', new Date(options.deprecationDate).toUTCString()); - // 2. Add Sunset header (if provided) - // The Sunset header indicates when the resource will become unavailable. if (options.sunsetDate) { res.setHeader('Sunset', new Date(options.sunsetDate).toUTCString()); } - // 3. Include alternative info (Link header) - // The Link header with rel="successor-version" can point to a newer version. if (options.alternativeUrl) { - // If there are existing Link headers, we should append to them. const existingLink = res.getHeader('Link'); const newLink = `<${options.alternativeUrl}>; rel="successor-version"`; - + if (existingLink) { if (Array.isArray(existingLink)) { res.setHeader('Link', [...existingLink, newLink]); @@ -56,9 +30,7 @@ export const deprecationMiddleware = (options: DeprecationOptions) => { } } - // 4. Log deprecation - // This helps server operators identify usage of deprecated endpoints. - console.warn(`[DEPRECATION WARNING] Client ${req.ip} accessed deprecated endpoint ${req.method} ${req.originalUrl}. Deprecated since: ${options.deprecationDate}.`); + logger.warn({ ip: req.ip, method: req.method, url: req.originalUrl, deprecationDate: options.deprecationDate }, 'Deprecated endpoint accessed'); next(); }; diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts new file mode 100644 index 00000000..b8b954b7 --- /dev/null +++ b/backend/src/middleware/index.ts @@ -0,0 +1,21 @@ +export { cacheControl, CacheTTL, type CacheOptions } from './cache.js'; +export { circuitBreaker, getCircuitState, getAllCircuits, resetCircuit } from './circuit-breaker.js'; +export { deprecationMiddleware, type DeprecationOptions } from './deprecation.js'; +export { AppError, asyncHandler, notFoundHandler, errorHandler } from './errorHandler.js'; +export { idempotency, clearIdempotencyCache } from './idempotency.js'; +export { initIpAllowlist, addBypassCode, removeBypassCode, ipAllowlistMiddleware, adminIpAllowlistMiddleware, apiIpAllowlistMiddleware, config as ipAllowlistConfig } from './ip-allowlist.js'; +export { logger, httpLogger } from './logger.js'; +export { type Role, type Action, type AbacContext, roleAtLeast, PermissionEngine, permissionEngine, requirePermission, attachAbacCtx } from './permissions.js'; +export { tokenBucketRateLimit, resolveUserTier, resolveClientKey, getAnalyticsSummary, type UserTier, type TokenBucketConfig, type RateLimitOptions, DEFAULT_TIER_CONFIGS, SANDBOX_TIER_CONFIGS, ENDPOINT_CONFIGS } from './rate-limit.js'; +export { requestIdMiddleware, REQUEST_ID_HEADER } from './requestId.js'; +export { requireFlag } from './requireFlag.js'; +export { sanitizeInput, contentSecurityPolicy, validateInput, InputSanitizer, createSecurityRateLimit, type SanitizeOptions } from './sanitize.js'; +export { SecurityMiddleware, SQLInjectionPrevention, CommandInjectionPrevention, XSSPrevention, InputValidation, SecurityMonitor } from './security.js'; +export { sessionMiddleware } from './session.js'; +export { slaTrackingMiddleware } from './slaTracking.js'; +export { traceMiddleware, TRACE_ID_HEADER } from './trace.js'; +export { cacheControlNoStore, CACHE_NOSTORE_HEADER, VARY_HEADER } from './cache-control.js'; +export { validate } from './validate.js'; +export { versionMiddleware } from './versioning.js'; +export { verifyWebhook, webhookVerifiers, rawBodyCapture, type WebhookVerificationConfig } from './webhookVerification.js'; +export { composeMiddleware, type MiddlewareFunction, type MiddlewareChain } from './compose.js'; diff --git a/backend/src/middleware/session.ts b/backend/src/middleware/session.ts index 885a36a7..fbbd3302 100644 --- a/backend/src/middleware/session.ts +++ b/backend/src/middleware/session.ts @@ -1,39 +1,31 @@ import { Request, Response, NextFunction } from 'express'; import { updateSessionActivity, getSession, checkSessionAnomaly } from '../services/session.js'; +import { AppError } from './errorHandler.js'; +import { logger } from './logger.js'; -/** - * Middleware to track session activity and detect anomalies. - */ export function sessionMiddleware(req: Request, res: Response, next: NextFunction) { const sessionId = req.headers['x-session-id'] as string; - + if (sessionId) { const session = getSession(sessionId); - + if (session) { if (session.status === 'terminated') { - return res.status(401).json({ - error: { - code: 'SESSION_TERMINATED', - message: 'Your session has been terminated. Please log in again.', - status: 401 - } - }); + next(new AppError(401, 'Your session has been terminated. Please log in again.', 'SESSION_TERMINATED')); + return; } const currentIp = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress || '127.0.0.1'; - - // Update activity + updateSessionActivity(sessionId, currentIp); - - // Check for anomalies + const anomaly = checkSessionAnomaly(session, currentIp); if (anomaly) { - console.warn(`[Session Anomaly] ${anomaly} for session ${sessionId} (User: ${session.userId})`); + logger.warn({ sessionId, userId: session.userId, anomaly }, 'Session anomaly detected'); res.setHeader('X-Session-Warning', anomaly); } } } - + next(); } diff --git a/backend/src/middleware/trace.ts b/backend/src/middleware/trace.ts new file mode 100644 index 00000000..7b2481d5 --- /dev/null +++ b/backend/src/middleware/trace.ts @@ -0,0 +1,20 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; + +export const TRACE_ID_HEADER = 'x-trace-id'; + +export const traceStorage = new AsyncLocalStorage(); + +export function traceMiddleware(req: Request, res: Response, next: NextFunction): void { + const traceId = (req.headers[TRACE_ID_HEADER] as string) || randomUUID(); + res.setHeader('X-Trace-Id', traceId); + + traceStorage.run(traceId, () => { + next(); + }); +} + +export function getTraceId(): string | undefined { + return traceStorage.getStore(); +}