diff --git a/.env.example b/.env.example index a9f451e..71edaaa 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,19 @@ PORT=3000 NODE_ENV=development +# HTTP Security +TRUST_PROXY=false +CORS_ORIGIN=http://localhost:3000 +# CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +CORS_ALLOW_CREDENTIALS=true +HTTP_BODY_SIZE_LIMIT=1mb +HTTP_SHUTDOWN_TIMEOUT_MS=15000 + +# Rate Limiting +RATE_LIMIT_ENABLED=true +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=100 + # Database DATABASE_URL=postgresql://user:password@localhost:5432/stellarsettle diff --git a/package-lock.json b/package-lock.json index 5256cfc..a69fd37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2236,7 +2236,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2425,7 +2424,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2992,7 +2990,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4020,7 +4017,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4289,7 +4285,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7275,7 +7270,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -8717,7 +8711,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8991,7 +8984,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/app.ts b/src/app.ts index 02fb044..bd92735 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,12 +2,15 @@ import cors from "cors"; import express from "express"; import helmet from "helmet"; import { createErrorMiddleware, notFoundMiddleware } from "./middleware/error.middleware"; +import { applyRateLimiters, createAuthRateLimitMiddleware } from "./middleware/rate-limit.middleware"; import { createRequestObservabilityMiddleware } from "./middleware/request-observability.middleware"; import { logger, type AppLogger } from "./observability/logger"; import { getMetricsContentType, MetricsRegistry } from "./observability/metrics"; import { createAuthRouter } from "./routes/auth.routes"; import { createInvoiceRouter } from "./routes/invoice.routes"; import type { AuthService } from "./services/auth.service"; +import type { ApiResponseEnvelope } from "./utils/http-error"; +import dataSource from "./config/database"; import type { InvoiceService } from "./services/invoice.service"; import type { AppConfig } from "./config/env"; @@ -23,6 +26,11 @@ export interface AppDependencies { corsAllowCredentials?: boolean; bodySizeLimit?: string; nodeEnv?: string; + rateLimit?: { + enabled?: boolean; + windowMs?: number; + max?: number; + }; }; ipfsConfig?: AppConfig["ipfs"]; requestLifecycleTracker?: RequestLifecycleTracker; @@ -124,6 +132,7 @@ export function createApp({ const bodySizeLimit = http?.bodySizeLimit ?? "1mb"; const trustProxy = http?.trustProxy ?? false; const nodeEnv = http?.nodeEnv ?? process.env.NODE_ENV ?? "development"; + const rateLimitEnabled = http?.rateLimit?.enabled ?? true; app.set("trust proxy", trustProxy); app.use(helmet()); @@ -137,6 +146,16 @@ export function createApp({ ), ); app.use(express.json({ limit: bodySizeLimit })); + + if (rateLimitEnabled) { + applyRateLimiters(app, appLogger, { + global: { + windowMs: http?.rateLimit?.windowMs, + max: http?.rateLimit?.max, + }, + }); + } + app.use((req, res, next) => { requestLifecycleTracker.onRequestStart(); const finalize = () => { @@ -158,11 +177,60 @@ export function createApp({ ); app.get("/health", (req, res) => { - res.status(200).json({ - status: "ok", - uptimeSeconds: Number(process.uptime().toFixed(3)), - requestId: req.requestId, - }); + const envelope: ApiResponseEnvelope<{ status: string; timestamp: string; uptimeSeconds: number; requestId: string }> = { + success: true, + data: { + status: "ok", + timestamp: new Date().toISOString(), + uptimeSeconds: Number(process.uptime().toFixed(3)), + requestId: req.requestId ?? "unknown", + }, + }; + res.status(200).json(envelope); + }); + + app.get("/health/db", async (req, res) => { + try { + if (!dataSource.isInitialized) { + const envelope: ApiResponseEnvelope<{ requestId: string }> = { + success: false, + error: { + code: "DB_NOT_INITIALIZED", + message: "Database connection is not initialized.", + }, + data: { + requestId: req.requestId ?? "unknown", + }, + }; + res.status(503).json(envelope); + return; + } + + await dataSource.query("SELECT 1"); + + const envelope: ApiResponseEnvelope<{ status: string; timestamp: string; connection: string; requestId: string }> = { + success: true, + data: { + status: "ok", + timestamp: new Date().toISOString(), + connection: "postgres", + requestId: req.requestId ?? "unknown", + }, + }; + res.status(200).json(envelope); + } catch (error) { + const envelope: ApiResponseEnvelope<{ requestId: string }> = { + success: false, + error: { + code: "DB_CONNECTION_ERROR", + message: "Database connection failed.", + }, + data: { + requestId: req.requestId ?? "unknown", + }, + }; + res.status(503).json(envelope); + } }); if (metricsEnabled) { @@ -172,7 +240,11 @@ export function createApp({ }); } - app.use("/api/v1/auth", createAuthRouter(authService)); + const authRouter = createAuthRouter(authService); + if (rateLimitEnabled) { + authRouter.use(createAuthRateLimitMiddleware(appLogger)); + } + app.use("/api/v1/auth", authRouter); // Add invoice routes if service is provided if (invoiceService && ipfsConfig) { diff --git a/src/config/env.ts b/src/config/env.ts index 8e670a6..77e1bcf 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -22,6 +22,11 @@ export interface AppConfig { corsAllowCredentials: boolean; bodySizeLimit: string; shutdownTimeoutMs: number; + rateLimit?: { + enabled: boolean; + windowMs: number; + max: number; + }; }; reconciliation: { enabled: boolean; @@ -225,7 +230,7 @@ export function getConfig(): AppConfig { }, http: { trustProxy: parseTrustProxy(process.env.TRUST_PROXY), - corsAllowedOrigins: parseCsv(process.env.CORS_ALLOWED_ORIGINS), + corsAllowedOrigins: parseCsv(process.env.CORS_ORIGIN ?? process.env.CORS_ALLOWED_ORIGINS), corsAllowCredentials: parseBoolean( process.env.CORS_ALLOW_CREDENTIALS, true, @@ -237,6 +242,15 @@ export function getConfig(): AppConfig { DEFAULT_SHUTDOWN_TIMEOUT_MS, "HTTP_SHUTDOWN_TIMEOUT_MS", ), + rateLimit: { + enabled: parseBoolean(process.env.RATE_LIMIT_ENABLED, true, "RATE_LIMIT_ENABLED"), + windowMs: parsePositiveInteger( + process.env.RATE_LIMIT_WINDOW_MS, + 60 * 1000, + "RATE_LIMIT_WINDOW_MS", + ), + max: parsePositiveInteger(process.env.RATE_LIMIT_MAX, 100, "RATE_LIMIT_MAX"), + }, }, reconciliation: { enabled: parseBoolean( diff --git a/src/index.ts b/src/index.ts index 1164f77..23fa207 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ export async function bootstrap(): Promise { corsAllowCredentials: config.http.corsAllowCredentials, bodySizeLimit: config.http.bodySizeLimit, nodeEnv: config.nodeEnv, + rateLimit: config.http.rateLimit, }, ipfsConfig: config.ipfs, requestLifecycleTracker, diff --git a/src/middleware/error.middleware.ts b/src/middleware/error.middleware.ts index 552bf0b..1a600f9 100644 --- a/src/middleware/error.middleware.ts +++ b/src/middleware/error.middleware.ts @@ -1,11 +1,16 @@ import type { NextFunction, Request, Response } from "express"; import type { AppLogger } from "../observability/logger"; -import { HttpError } from "../utils/http-error"; +import type { ApiResponseEnvelope } from "../utils/http-error"; +import { AppError, HttpError } from "../utils/http-error"; export function notFoundMiddleware(_req: Request, _res: Response, next: NextFunction) { next(new HttpError(404, "Route not found.")); } +function sendEnvelopeResponse(res: Response, statusCode: number, payload: ApiResponseEnvelope) { + res.status(statusCode).json(payload); +} + export function createErrorMiddleware(logger: AppLogger) { return ( error: unknown, @@ -15,18 +20,25 @@ export function createErrorMiddleware(logger: AppLogger) { ): void => { void next; - if (error instanceof HttpError) { + if (error instanceof AppError || error instanceof HttpError) { logger.warn("HTTP request failed.", { requestId: req.requestId, method: req.method, path: req.path, statusCode: error.statusCode, + code: error.code, error: error.message, }); - res.status(error.statusCode).json({ - error: error.message, - details: error.details, - }); + + const envelope: ApiResponseEnvelope = { + success: false, + error: { + code: error.code, + message: error.message, + }, + }; + + sendEnvelopeResponse(res, error.statusCode, envelope); return; } @@ -38,8 +50,14 @@ export function createErrorMiddleware(logger: AppLogger) { error: error instanceof Error ? error.message : "Unknown error", }); - res.status(500).json({ - error: "Internal server error.", - }); + const envelope: ApiResponseEnvelope = { + success: false, + error: { + code: "INTERNAL_ERROR", + message: "Internal server error.", + }, + }; + + sendEnvelopeResponse(res, 500, envelope); }; } diff --git a/src/middleware/rate-limit.middleware.ts b/src/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..3bddcb9 --- /dev/null +++ b/src/middleware/rate-limit.middleware.ts @@ -0,0 +1,81 @@ +import rateLimit from "express-rate-limit"; +import type { AppLogger } from "../observability/logger"; +import { HttpError } from "../utils/http-error"; + +export interface RateLimitOptions { + windowMs: number; + max: number; + message?: string; + code?: string; +} + +const DEFAULT_GLOBAL_LIMIT: RateLimitOptions = { + windowMs: 60 * 1000, + max: 100, + message: "Too many requests, please try again later.", + code: "RATE_LIMIT_EXCEEDED", +}; + +const DEFAULT_AUTH_LIMIT: RateLimitOptions = { + windowMs: 15 * 60 * 1000, + max: 10, + message: "Too many authentication attempts, please try again later.", + code: "AUTH_RATE_LIMIT_EXCEEDED", +}; + +export function createRateLimitMiddleware( + logger: AppLogger, + options: RateLimitOptions = DEFAULT_GLOBAL_LIMIT, +) { + const limiter = rateLimit({ + windowMs: options.windowMs, + max: options.max, + message: { + success: false, + error: { + code: options.code ?? "RATE_LIMIT_EXCEEDED", + message: options.message ?? "Too many requests, please try again later.", + }, + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res, next, nextOptions) => { + logger.warn("Rate limit exceeded.", { + requestId: req.requestId, + method: req.method, + path: req.path, + ip: req.ip, + }); + + const error = new HttpError( + 429, + nextOptions?.message ?? "Too many requests, please try again later.", + ); + + next(error); + }, + }); + + return limiter; +} + +export function createAuthRateLimitMiddleware(logger: AppLogger) { + return createRateLimitMiddleware(logger, DEFAULT_AUTH_LIMIT); +} + +export function applyRateLimiters( + app: { use: (middleware: unknown) => void }, + logger: AppLogger, + config?: { + global?: Partial; + auth?: Partial; + }, +) { + const globalOptions: RateLimitOptions = { + ...DEFAULT_GLOBAL_LIMIT, + ...config?.global, + }; + + const globalLimiter = createRateLimitMiddleware(logger, globalOptions); + app.use(globalLimiter); +} diff --git a/src/utils/http-error.ts b/src/utils/http-error.ts index 295600f..25aa445 100644 --- a/src/utils/http-error.ts +++ b/src/utils/http-error.ts @@ -1,11 +1,43 @@ +export interface ErrorPayload { + code: string; + message: string; +} + +export interface ApiResponseEnvelope { + success: boolean; + data?: T; + error?: ErrorPayload; + meta?: { + page?: number; + limit?: number; + total?: number; + }; +} + +export class AppError extends Error { + statusCode: number; + code: string; + details?: unknown; + + constructor(statusCode: number, message: string, code: string, details?: unknown) { + super(message); + this.name = "AppError"; + this.statusCode = statusCode; + this.code = code; + this.details = details; + } +} + export class HttpError extends Error { statusCode: number; + code: string; details?: unknown; constructor(statusCode: number, message: string, details?: unknown) { super(message); this.name = "HttpError"; this.statusCode = statusCode; + this.code = `HTTP_${statusCode}`; this.details = details; } } diff --git a/tests/api-envelope.test.ts b/tests/api-envelope.test.ts new file mode 100644 index 0000000..0c03b9f --- /dev/null +++ b/tests/api-envelope.test.ts @@ -0,0 +1,301 @@ +import request from "supertest"; +import { createApp, createRequestLifecycleTracker } from "../src/app"; +import type { AuthService } from "../src/services/auth.service"; + +function createAuthServiceStub(): AuthService { + return { + createChallenge: async () => { + throw new Error("Not implemented."); + }, + verifyChallenge: async () => { + throw new Error("Not implemented."); + }, + getCurrentUser: async () => { + throw new Error("Not implemented."); + }, + } as unknown as AuthService; +} + +describe("Response envelope", () => { + it("returns success envelope from /health", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app).get("/health").expect(200); + + expect(response.body).toMatchObject({ + success: true, + data: { + status: "ok", + timestamp: expect.any(String), + uptimeSeconds: expect.any(Number), + requestId: expect.any(String), + }, + }); + expect(response.body.error).toBeUndefined(); + }); + + it("returns error envelope for unknown routes (404)", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app).get("/nonexistent-route").expect(404); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + expect(response.body.data).toBeUndefined(); + }); + + it("returns error envelope for unhandled errors (500)", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + // Add a route that throws an error before the error middleware is applied + // We use the router pattern to ensure the route is matched before notFoundMiddleware + const router = request.agent(app); + + // Simulate an internal error by accessing a route that will throw + // Since we can't add routes after app creation, we'll test via the auth routes + // which will throw an error from the stub service + const response = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: "test" }) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); +}); + +describe("Health endpoints", () => { + it("GET /health returns 200 with status ok", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app).get("/health").expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data?.status).toBe("ok"); + expect(response.body.data?.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(typeof response.body.data?.uptimeSeconds).toBe("number"); + }); + + it("GET /health/db returns 503 when DB is not initialized", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app).get("/health/db").expect(503); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: "DB_NOT_INITIALIZED", + message: "Database connection is not initialized.", + }, + }); + }, 15000); +}); + +describe("Rate limiting", () => { + it("applies global rate limiter when enabled", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: true, + windowMs: 1000, + max: 2, + }, + }, + }); + + await request(app).get("/health").expect(200); + await request(app).get("/health").expect(200); + + const response = await request(app).get("/health").expect(429); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); + + it("skips rate limiting when disabled", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + for (let i = 0; i < 10; i++) { + await request(app).get("/health").expect(200); + } + }); +}); + +describe("CORS configuration", () => { + it("allows configured CORS origins", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + nodeEnv: "production", + corsAllowedOrigins: ["https://example.com"], + corsAllowCredentials: true, + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app) + .get("/health") + .set("Origin", "https://example.com") + .expect(200); + + expect(response.headers["access-control-allow-origin"]).toBe("https://example.com"); + }); + + it("blocks unconfigured origins in production", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + nodeEnv: "production", + corsAllowedOrigins: [], + corsAllowCredentials: false, + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app) + .get("/health") + .set("Origin", "https://evil.com") + .expect(200); + + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + }); +}); + +describe("Security headers (helmet)", () => { + it("sets security headers via helmet", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app).get("/health").expect(200); + + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(response.headers["x-frame-options"]).toBe("SAMEORIGIN"); + }); +}); + +describe("API v1 routing", () => { + it("mounts auth routes under /api/v1/auth", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" }) + .expect(500); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); + + it("returns 404 for routes outside /api/v1", async () => { + const app = createApp({ + authService: createAuthServiceStub(), + metricsEnabled: false, + http: { + rateLimit: { + enabled: false, + }, + }, + }); + + const response = await request(app).get("/api/v2/unknown").expect(404); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: expect.any(String), + message: expect.any(String), + }, + }); + }); +}); diff --git a/tests/app-error.test.ts b/tests/app-error.test.ts new file mode 100644 index 0000000..e46c600 --- /dev/null +++ b/tests/app-error.test.ts @@ -0,0 +1,51 @@ +import { AppError, HttpError } from "../src/utils/http-error"; + +describe("AppError", () => { + it("creates an AppError with statusCode, code, and message", () => { + const error = new AppError(400, "Invalid input", "INVALID_INPUT"); + + expect(error.statusCode).toBe(400); + expect(error.code).toBe("INVALID_INPUT"); + expect(error.message).toBe("Invalid input"); + expect(error.name).toBe("AppError"); + }); + + it("includes optional details", () => { + const details = { field: "email", reason: "required" }; + const error = new AppError(400, "Invalid email", "INVALID_EMAIL", details); + + expect(error.details).toEqual(details); + }); + + it("extends Error and has proper stack trace", () => { + const error = new AppError(500, "Database error", "DB_ERROR"); + + expect(error).toBeInstanceOf(Error); + expect(error.stack).toBeDefined(); + }); +}); + +describe("HttpError", () => { + it("creates an HttpError with statusCode and auto-generated code", () => { + const error = new HttpError(404, "Not found"); + + expect(error.statusCode).toBe(404); + expect(error.code).toBe("HTTP_404"); + expect(error.message).toBe("Not found"); + expect(error.name).toBe("HttpError"); + }); + + it("includes optional details", () => { + const details = { path: "/api/users/123" }; + const error = new HttpError(404, "User not found", details); + + expect(error.details).toEqual(details); + }); + + it("extends Error and has proper stack trace", () => { + const error = new HttpError(503, "Service unavailable"); + + expect(error).toBeInstanceOf(Error); + expect(error.stack).toBeDefined(); + }); +}); diff --git a/tests/auth.routes.test.ts b/tests/auth.routes.test.ts index 9d00017..c76e0ce 100644 --- a/tests/auth.routes.test.ts +++ b/tests/auth.routes.test.ts @@ -218,6 +218,11 @@ describe("Auth routes", () => { const response = await request(app).get("/api/v1/auth/me").expect(401); - expect(response.body.error).toBe("Authorization token is required."); + expect(response.body).toMatchObject({ + success: false, + error: { + message: "Authorization token is required.", + }, + }); }); }); diff --git a/tests/observability.test.ts b/tests/observability.test.ts index e891213..f96fc54 100644 --- a/tests/observability.test.ts +++ b/tests/observability.test.ts @@ -119,7 +119,7 @@ describe("Observability", () => { .expect(200); expect(response.headers["x-request-id"]).toBe("client-request-id"); - expect(response.body.requestId).toBe("client-request-id"); + expect(response.body.data?.requestId).toBe("client-request-id"); }); it("exposes Prometheus metrics for matched routes and unmatched requests", async () => {