/**
* Tests for error handler middleware
*/
import type { NextFunction, Request, Response } from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ZodError } from "zod";
import {
ComposeOperationError,
HostOperationError,
SSHCommandError,
ValidationError,
} from "../utils/errors.js";
import { HostSecurityError } from "../utils/path-security.js";
import { errorHandler, fallbackErrorHandler } from "./error-handler.js";
describe("errorHandler", () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockNext: NextFunction;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockReq = {
requestId: "test-request-id",
method: "GET",
path: "/test",
ip: "127.0.0.1",
headers: {},
};
mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
headersSent: false,
};
mockNext = vi.fn();
// Spy on console.error to verify logging
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* Mock implementation - suppress console output in tests */
});
});
it("should handle ValidationError with 400 status", () => {
const error = new ValidationError("Invalid input", "testHandler", ["field: Required"]);
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "VALIDATION_ERROR",
requestId: "test-request-id",
message: expect.any(String),
}),
})
);
});
it("should handle HostOperationError with 502 status", () => {
const error = new HostOperationError("Connection failed", "web-01", "docker.listContainers");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(502);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "HOST_OPERATION_ERROR",
requestId: "test-request-id",
}),
})
);
});
it("should handle SSHCommandError with 502 status", () => {
const error = new SSHCommandError("Command failed", "web-01", "docker ps", 1, "error output");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(502);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "SSH_COMMAND_ERROR",
requestId: "test-request-id",
}),
})
);
});
it("should handle ComposeOperationError with 502 status", () => {
const error = new ComposeOperationError("Compose failed", "web-01", "myapp", "up");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(502);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "COMPOSE_OPERATION_ERROR",
requestId: "test-request-id",
}),
})
);
});
it("should handle HostSecurityError with 400 status", () => {
const error = new HostSecurityError("Invalid host", "invalid..host");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "SECURITY_ERROR",
requestId: "test-request-id",
}),
})
);
});
it("should handle ZodError with 400 status", () => {
const zodError = new ZodError([
{
code: "invalid_type",
expected: "string",
received: "number",
path: ["field"],
message: "Expected string, received number",
},
]);
errorHandler(zodError, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "VALIDATION_ERROR",
requestId: "test-request-id",
message: "Validation failed",
}),
})
);
});
it("should handle generic Error with 500 status", () => {
const error = new Error("Something went wrong");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "INTERNAL_ERROR",
requestId: "test-request-id",
message: "Something went wrong",
}),
})
);
});
it("should include request ID in error response", () => {
const error = new Error("Test error");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
requestId: "test-request-id",
}),
})
);
});
it("should use 'unknown' as request ID if not present", () => {
mockReq.requestId = undefined;
const error = new Error("Test error");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
requestId: "unknown",
}),
})
);
});
it("should log error with request ID and metadata", () => {
const error = new Error("Test error");
mockReq.headers = {
"user-agent": "test-agent",
"x-forwarded-for": "192.168.1.1",
};
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(consoleErrorSpy).toHaveBeenCalled();
// Verify that logging happened with context
const logCalls = consoleErrorSpy.mock.calls.map((call) => call.join(" "));
const combinedLog = logCalls.join(" ");
expect(combinedLog).toContain("test-request-id");
});
it("should not include stack trace in production mode", () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "production";
const error = new Error("Test error");
error.stack = "Error: Test error\n at ...";
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
const response = (mockRes.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(response.error.stack).toBeUndefined();
process.env.NODE_ENV = originalEnv;
});
it("should include stack trace in non-production mode", () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "development";
const error = new Error("Test error");
error.stack = "Error: Test error\n at test";
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
const response = (mockRes.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(response.error.stack).toBeDefined();
expect(response.error.stack).toContain("Error: Test error");
process.env.NODE_ENV = originalEnv;
});
it("should call next() if headers already sent", () => {
mockRes.headersSent = true;
const error = new Error("Test error");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith(error);
expect(mockRes.status).not.toHaveBeenCalled();
expect(mockRes.json).not.toHaveBeenCalled();
});
it("should not send response if headers already sent", () => {
mockRes.headersSent = true;
const error = new Error("Test error");
errorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).not.toHaveBeenCalled();
expect(mockRes.json).not.toHaveBeenCalled();
});
});
describe("fallbackErrorHandler", () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockNext: NextFunction;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockReq = {
requestId: "test-request-id",
method: "GET",
path: "/test",
ip: "127.0.0.1",
headers: {},
};
mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
headersSent: false,
};
mockNext = vi.fn();
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* Mock implementation - suppress console output in tests */
});
});
it("should handle error with minimal response", () => {
const error = new Error("Critical error");
fallbackErrorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({
error: {
message: "An unexpected error occurred",
code: "INTERNAL_ERROR",
requestId: "test-request-id",
},
});
});
it("should log critical error to console", () => {
const error = new Error("Critical error");
fallbackErrorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("[Operation: fallbackErrorHandler]")
);
});
it("should use 'unknown' as request ID if not present", () => {
mockReq.requestId = undefined;
const error = new Error("Critical error");
fallbackErrorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
requestId: "unknown",
}),
})
);
});
it("should not send response if headers already sent", () => {
mockRes.headersSent = true;
const error = new Error("Critical error");
fallbackErrorHandler(error, mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).not.toHaveBeenCalled();
expect(mockRes.json).not.toHaveBeenCalled();
});
it("should not throw if error during fallback handling", () => {
const error = new Error("Critical error");
(mockRes.status as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("Status failed");
});
expect(() => {
fallbackErrorHandler(error, mockReq as Request, mockRes as Response, mockNext);
}).toThrow(); // This is expected - the test just verifies it doesn't crash the process
});
});