/**
* Error Handling Integration Tests
*
* End-to-end tests verifying the complete middleware chain works together:
* - Request ID middleware
* - Error mapper
* - Error handler middleware
* - Async handler wrapper
* - Rate limiter compatibility
*/
import type { Application } from "express";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it } from "vitest";
import { ZodError } from "zod";
import { createHealthRateLimiter } from "../health-rate-limiter.js";
import {
ComposeOperationError,
HostOperationError,
SSHCommandError,
ValidationError,
} from "../utils/errors.js";
import { HostSecurityError, SSHArgSecurityError } from "../utils/path-security.js";
import { asyncHandler, errorHandler, fallbackErrorHandler, requestIdMiddleware } from "./index.js";
describe("Error Handling Integration", () => {
let app: Application;
beforeEach(() => {
app = express();
// Phase 1: Request ID middleware (MUST be first)
app.use(requestIdMiddleware);
// Body parsing middleware
app.use(express.json());
// Test routes with different error types (using asyncHandler)
// Success route
app.get(
"/success",
asyncHandler(async (_req, res) => {
res.json({ status: "ok" });
})
);
// Success route with request ID access
app.get(
"/success-with-id",
asyncHandler(async (req, res) => {
res.json({ status: "ok", requestId: req.requestId });
})
);
// ValidationError (400)
app.get(
"/validation-error",
asyncHandler(async () => {
throw new ValidationError("Invalid input", "testHandler", ["field: Required"]);
})
);
// HostOperationError (502)
app.get(
"/host-error",
asyncHandler(async () => {
throw new HostOperationError("Connection failed", "testHost", "connect");
})
);
// SSHCommandError (502)
app.get(
"/ssh-error",
asyncHandler(async () => {
throw new SSHCommandError("Command failed", "testHost", "ls -la", 1, "Permission denied");
})
);
// ComposeOperationError (502)
app.get(
"/compose-error",
asyncHandler(async () => {
throw new ComposeOperationError("Deploy failed", "testHost", "myapp", "up");
})
);
// HostSecurityError (400)
app.get(
"/host-security-error",
asyncHandler(async () => {
throw new HostSecurityError("Invalid hostname format", "bad;host");
})
);
// SSHArgSecurityError (400)
app.get(
"/ssh-arg-error",
asyncHandler(async () => {
throw new SSHArgSecurityError("Invalid argument", "bad;arg", "status");
})
);
// ZodError (400)
app.get(
"/zod-error",
asyncHandler(async () => {
const zodError = new ZodError([
{
code: "invalid_type",
expected: "string",
received: "number",
path: ["username"],
message: "Expected string, received number",
},
{
code: "too_small",
minimum: 3,
type: "string",
inclusive: true,
exact: false,
path: ["password"],
message: "String must contain at least 3 character(s)",
},
]);
throw zodError;
})
);
// Generic Error (500)
app.get(
"/generic-error",
asyncHandler(async () => {
throw new Error("Something went wrong");
})
);
// String error (500)
app.get(
"/string-error",
asyncHandler(async () => {
throw "String error message";
})
);
// Health endpoint with rate limiter
app.get("/health", createHealthRateLimiter(), (_req, res) => {
res.json({ status: "healthy" });
});
// MCP endpoint simulation (with asyncHandler)
app.post(
"/mcp",
asyncHandler(async (req, res) => {
if (req.body.error) {
throw new Error("MCP processing failed");
}
res.json({ result: "success" });
})
);
// Phase 3: Error handlers (MUST be last)
app.use(errorHandler);
app.use(fallbackErrorHandler);
});
describe("ValidationError handling", () => {
it("should handle ValidationError with 400 status", async () => {
const res = await request(app).get("/validation-error");
expect(res.status).toBe(400);
expect(res.body.error.code).toBe("VALIDATION_ERROR");
expect(res.body.error.message).toContain("Invalid input");
expect(res.body.error.details).toEqual({
handler: "testHandler",
issues: ["field: Required"],
});
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("HostOperationError handling", () => {
it("should handle HostOperationError with 502 status", async () => {
const res = await request(app).get("/host-error");
expect(res.status).toBe(502);
expect(res.body.error.code).toBe("HOST_OPERATION_ERROR");
expect(res.body.error.message).toContain("Connection failed");
expect(res.body.error.message).toContain("testHost");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("SSHCommandError handling", () => {
it("should handle SSHCommandError with 502 status", async () => {
const res = await request(app).get("/ssh-error");
expect(res.status).toBe(502);
expect(res.body.error.code).toBe("SSH_COMMAND_ERROR");
expect(res.body.error.message).toContain("Command failed");
expect(res.body.error.message).toContain("testHost");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("ComposeOperationError handling", () => {
it("should handle ComposeOperationError with 502 status", async () => {
const res = await request(app).get("/compose-error");
expect(res.status).toBe(502);
expect(res.body.error.code).toBe("COMPOSE_OPERATION_ERROR");
expect(res.body.error.message).toContain("Deploy failed");
expect(res.body.error.message).toContain("testHost");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("Security error handling", () => {
it("should handle HostSecurityError with 400 status", async () => {
const res = await request(app).get("/host-security-error");
expect(res.status).toBe(400);
expect(res.body.error.code).toBe("SECURITY_ERROR");
expect(res.body.error.message).toContain("Invalid hostname format");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
it("should handle SSHArgSecurityError with 400 status", async () => {
const res = await request(app).get("/ssh-arg-error");
expect(res.status).toBe(400);
expect(res.body.error.code).toBe("SECURITY_ERROR");
expect(res.body.error.message).toContain("Invalid argument");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("ZodError handling", () => {
it("should handle ZodError with 400 status and formatted issues", async () => {
const res = await request(app).get("/zod-error");
expect(res.status).toBe(400);
expect(res.body.error.code).toBe("VALIDATION_ERROR");
expect(res.body.error.message).toBe("Validation failed");
expect(res.body.error.details).toEqual({
issues: [
"username: Expected string, received number",
"password: String must contain at least 3 character(s)",
],
});
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("Generic error handling", () => {
it("should handle generic Error with 500 status", async () => {
const res = await request(app).get("/generic-error");
expect(res.status).toBe(500);
expect(res.body.error.code).toBe("INTERNAL_ERROR");
expect(res.body.error.message).toBe("Something went wrong");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
it("should handle string errors with 500 status", async () => {
const res = await request(app).get("/string-error");
expect(res.status).toBe(500);
expect(res.body.error.code).toBe("INTERNAL_ERROR");
expect(res.body.error.message).toBe("String error message");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("Request ID propagation", () => {
it("should propagate request ID through success chain", async () => {
const res = await request(app).get("/success-with-id");
expect(res.status).toBe(200);
expect(res.body.status).toBe("ok");
expect(res.body.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toBe(res.body.requestId);
});
it("should propagate request ID through error chain", async () => {
const res = await request(app).get("/validation-error");
expect(res.status).toBe(400);
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res.headers["x-request-id"]).toBe(res.body.error.requestId);
});
it("should generate unique request IDs for each request", async () => {
const res1 = await request(app).get("/success-with-id");
const res2 = await request(app).get("/success-with-id");
const res3 = await request(app).get("/success-with-id");
expect(res1.body.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res2.body.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res3.body.requestId).toMatch(/^[0-9a-f-]{36}$/);
expect(res1.body.requestId).not.toBe(res2.body.requestId);
expect(res2.body.requestId).not.toBe(res3.body.requestId);
expect(res1.body.requestId).not.toBe(res3.body.requestId);
});
});
describe("Concurrent requests", () => {
it("should handle concurrent requests with unique IDs", async () => {
const promises = Array.from({ length: 100 }, () => request(app).get("/success-with-id"));
const results = await Promise.all(promises);
// Verify all succeeded
expect(results.every((r) => r.status === 200)).toBe(true);
// Verify all have unique request IDs
const requestIds = results.map((r) => r.body.requestId);
const uniqueIds = new Set(requestIds);
expect(uniqueIds.size).toBe(100);
// Verify all match UUID v4 format
expect(requestIds.every((id) => /^[0-9a-f-]{36}$/.test(id))).toBe(true);
});
it("should handle concurrent error requests with unique IDs", async () => {
const promises = Array.from({ length: 50 }, () => request(app).get("/validation-error"));
const results = await Promise.all(promises);
// Verify all failed with correct status
expect(results.every((r) => r.status === 400)).toBe(true);
// Verify all have unique request IDs
const requestIds = results.map((r) => r.body.error.requestId);
const uniqueIds = new Set(requestIds);
expect(uniqueIds.size).toBe(50);
});
});
describe("Rate limiter compatibility", () => {
it("should allow requests under rate limit", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body.status).toBe("healthy");
expect(res.headers["ratelimit-limit"]).toBe("30");
expect(res.headers["ratelimit-remaining"]).toBeDefined();
expect(res.headers["ratelimit-reset"]).toBeDefined();
});
it("should rate limit excessive requests", async () => {
// Make 31 requests (limit is 30)
const promises = Array.from({ length: 31 }, () => request(app).get("/health"));
const results = await Promise.all(promises);
// First 30 should succeed
const successes = results.filter((r) => r.status === 200);
const failures = results.filter((r) => r.status === 429);
expect(successes.length).toBe(30);
expect(failures.length).toBe(1);
// Rate limited response should have error structure
const rateLimitedRes = failures[0];
expect(rateLimitedRes.status).toBe(429);
expect(rateLimitedRes.body.error).toBeDefined();
expect(rateLimitedRes.headers["retry-after"]).toBeDefined();
});
});
describe("MCP endpoint error handling", () => {
it("should handle MCP endpoint success with asyncHandler", async () => {
const res = await request(app)
.post("/mcp")
.send({ data: "test" })
.set("Content-Type", "application/json");
expect(res.status).toBe(200);
expect(res.body.result).toBe("success");
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
it("should handle MCP endpoint errors with asyncHandler", async () => {
const res = await request(app)
.post("/mcp")
.send({ error: true })
.set("Content-Type", "application/json");
expect(res.status).toBe(500);
expect(res.body.error.code).toBe("INTERNAL_ERROR");
expect(res.body.error.message).toBe("MCP processing failed");
expect(res.body.error.requestId).toMatch(/^[0-9a-f-]{36}$/);
});
});
describe("Success cases", () => {
it("should handle successful requests", async () => {
const res = await request(app).get("/success");
expect(res.status).toBe(200);
expect(res.body.status).toBe("ok");
expect(res.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});
it("should not trigger error middleware for successful requests", async () => {
const res = await request(app).get("/success-with-id");
expect(res.status).toBe(200);
expect(res.body).not.toHaveProperty("error");
expect(res.body.status).toBe("ok");
});
});
describe("Stack trace handling", () => {
it("should include stack trace in non-production", async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "development";
const res = await request(app).get("/generic-error");
expect(res.status).toBe(500);
expect(res.body.error.stack).toBeDefined();
expect(typeof res.body.error.stack).toBe("string");
process.env.NODE_ENV = originalEnv;
});
it("should not include stack trace in production", async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "production";
const res = await request(app).get("/generic-error");
expect(res.status).toBe(500);
expect(res.body.error.stack).toBeUndefined();
process.env.NODE_ENV = originalEnv;
});
});
describe("Middleware chain order", () => {
it("should apply middleware in correct order for success", async () => {
const res = await request(app).get("/success-with-id");
// Request ID should be set (by requestIdMiddleware)
expect(res.headers["x-request-id"]).toBeDefined();
expect(res.body.requestId).toBe(res.headers["x-request-id"]);
// Route handler should have executed (by asyncHandler)
expect(res.body.status).toBe("ok");
// No error handler triggered
expect(res.body.error).toBeUndefined();
});
it("should apply middleware in correct order for error", async () => {
const res = await request(app).get("/validation-error");
// Request ID should be set (by requestIdMiddleware)
expect(res.headers["x-request-id"]).toBeDefined();
expect(res.body.error.requestId).toBe(res.headers["x-request-id"]);
// Error handler should have executed
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe("VALIDATION_ERROR");
// Error mapper should have worked
expect(res.status).toBe(400);
});
});
});