import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ComposeOperationError,
HostOperationError,
logError,
SSHCommandError,
sanitizeParams,
ValidationError,
} from "./errors.js";
describe("HostOperationError", () => {
it("should chain error causes and preserve stack", () => {
const rootCause = new Error("Connection timeout");
const wrapped = new HostOperationError(
"Failed to connect to host",
"docker-01",
"getDockerInfo",
rootCause
);
expect(wrapped.message).toContain("Failed to connect to host");
expect(wrapped.message).toContain("docker-01");
expect(wrapped.message).toContain("getDockerInfo");
expect(wrapped.cause).toBe(rootCause);
expect(wrapped.hostName).toBe("docker-01");
expect(wrapped.operation).toBe("getDockerInfo");
});
it("should handle non-Error cause types", () => {
const wrapped = new HostOperationError("Operation failed", "host-1", "test", "string error");
expect(wrapped.message).toContain("Operation failed");
expect(wrapped.cause).toBe("string error");
});
it("should pass cause to super() for native ES2022 error chaining", () => {
const rootCause = new Error("Connection timeout");
const wrapped = new HostOperationError("Failed", "docker-01", "getDockerInfo", rootCause);
// Verify cause is set via native Error mechanism, not just a class field
// Native Error.cause is set on the instance by the Error constructor,
// but it should NOT be a TypeScript-declared 'readonly' property with a getter
expect(wrapped.cause).toBe(rootCause);
// The cause should be accessible even when serialized through standard Error mechanisms
const serialized = JSON.parse(JSON.stringify(wrapped, Object.getOwnPropertyNames(wrapped)));
expect(serialized).toHaveProperty("cause");
});
});
describe("SSHCommandError", () => {
it("should include command, exit code, and stderr in message", () => {
const error = new SSHCommandError(
"Command failed",
"web-01",
"docker ps",
127,
"command not found",
""
);
expect(error.message).toContain("Command failed");
expect(error.message).toContain("web-01");
expect(error.message).toContain("docker ps");
expect(error.message).toContain("127");
expect(error.message).toContain("command not found");
expect(error.command).toBe("docker ps");
expect(error.exitCode).toBe(127);
});
it("should chain original error cause", () => {
const rootCause = new Error("Network timeout");
const error = new SSHCommandError(
"SSH failed",
"db-01",
"uptime",
undefined,
undefined,
undefined,
rootCause
);
expect(error.cause).toBe(rootCause);
expect(error.stack).toContain("Caused by:");
});
it("should pass cause to super() for native ES2022 error chaining", () => {
const rootCause = new Error("Network timeout");
const error = new SSHCommandError(
"SSH failed",
"db-01",
"uptime",
undefined,
undefined,
undefined,
rootCause
);
// cause should be set via native Error constructor options
expect(error.cause).toBe(rootCause);
// Verify it's enumerable via getOwnPropertyNames (native Error.cause behavior)
expect(Object.getOwnPropertyNames(error)).toContain("cause");
});
});
describe("ComposeOperationError", () => {
it("should include project and action in message", () => {
const error = new ComposeOperationError(
"Service failed to start",
"docker-01",
"production-db",
"up",
new Error("Port already in use")
);
expect(error.message).toContain("Service failed to start");
expect(error.message).toContain("docker-01");
expect(error.message).toContain("production-db");
expect(error.message).toContain("up");
expect(error.project).toBe("production-db");
expect(error.action).toBe("up");
});
it("should pass cause to super() for native ES2022 error chaining", () => {
const rootCause = new Error("Port already in use");
const error = new ComposeOperationError(
"Service failed",
"docker-01",
"production-db",
"up",
rootCause
);
expect(error.cause).toBe(rootCause);
});
});
describe("logError", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* Mock implementation */
});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it("should log structured error with context", () => {
const error = new HostOperationError(
"Connection failed",
"docker-01",
"listContainers",
new Error("ECONNREFUSED")
);
logError(error, { requestId: "req-123" });
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("HostOperationError"));
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("docker-01"));
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("listContainers"));
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("req-123"));
});
it("should handle non-Error types", () => {
logError("string error", { operation: "test" });
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("string error"));
});
it("should include stack trace for Error instances", () => {
const error = new Error("Test error");
logError(error);
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining(error.stack || ""));
});
});
describe("ValidationError", () => {
it("should include handler name in message", () => {
const error = new ValidationError("host field required", "scout-logs", ["host: Required"]);
expect(error.message).toContain("[Handler: scout-logs]");
expect(error.handlerName).toBe("scout-logs");
});
it("should include issues array", () => {
const issues = ["host: Required", "lines: Must be positive"];
const error = new ValidationError("validation failed", "docker", issues);
expect(error.issues).toEqual(issues);
expect(error.message).toContain("host: Required");
});
it("should preserve cause chain", () => {
const zodError = new Error("Zod parse error");
const error = new ValidationError("failed", "compose", ["field: invalid"], zodError);
expect(error.cause).toBe(zodError);
expect(error.stack).toContain("Caused by:");
});
it("should pass cause to super() for native ES2022 error chaining", () => {
const zodError = new Error("Zod parse error");
const error = new ValidationError("failed", "compose", ["field: invalid"], zodError);
// cause should be set via native Error constructor, not a manually declared field
expect(error.cause).toBe(zodError);
});
it("should have correct error name", () => {
const error = new ValidationError("test", "handler", []);
expect(error.name).toBe("ValidationError");
});
it("should format message with bracket notation", () => {
const error = new ValidationError("test message", "host", ["a: b"]);
expect(error.message).toMatch(/^\[Validation\] \[Handler: host\]/);
});
});
describe("sanitizeParams", () => {
it("should preserve safe fields (action, subaction)", () => {
const params = {
action: "container",
subaction: "list",
host: "docker-01",
};
const result = sanitizeParams(params);
// Operational identifiers like host are preserved for debugging
expect(result).toEqual({
action: "container",
subaction: "list",
host: "docker-01",
});
});
it("should redact sensitive fields (path, command, grep, filter)", () => {
const params = {
action: "scout",
subaction: "exec",
host: "web-01",
command: "cat /etc/passwd",
path: "/sensitive/config.yaml",
grep: "password",
label_filter: "env=production",
};
const result = sanitizeParams(params);
// Host is preserved for debugging; commands, paths, and filters are redacted
expect(result).toEqual({
action: "scout",
subaction: "exec",
host: "web-01",
command: "[REDACTED]",
path: "[REDACTED]",
grep: "[REDACTED]",
label_filter: "[REDACTED]",
});
});
it("should handle params without sensitive fields", () => {
const params = {
action: "docker",
subaction: "info",
host: "docker-01",
response_format: "json",
};
const result = sanitizeParams(params);
// Host is an operational identifier, not sensitive - preserved for debugging
expect(result).toEqual({
action: "docker",
subaction: "info",
host: "docker-01",
response_format: "json",
});
});
it("should handle non-object params", () => {
expect(sanitizeParams("string")).toBe("string");
expect(sanitizeParams(123)).toBe(123);
expect(sanitizeParams(null)).toBe(null);
expect(sanitizeParams(undefined)).toBe(undefined);
});
it("should handle nested objects by redacting entire value", () => {
const params = {
action: "test",
nested: {
sensitive: "data",
path: "/secret",
},
};
const result = sanitizeParams(params);
expect(result).toEqual({
action: "test",
nested: "[REDACTED]",
});
});
});