Files
SnapOtter/tests/unit/shared/error-sanitize.test.ts
T
SnapOtterandGitHub 04ef1141fb feat(telemetry): readable Sentry errors, Python tracebacks, and diagnostic mode
Keeps a real, redacted error message instead of "Error: Error", surfaces Python tracebacks in Sentry as a vetted context, and adds an opt-in SNAPOTTER_SENTRY_DIAGNOSTIC verbose mode plus SNAPOTTER_SENTRY_DSN_OVERRIDE. The default fleet path ships nothing on the never-collect list; raw detail is reachable only via the opt-in flag. Also classifies Redis OOM/READONLY replies as operational and removes a ReDoS in stack-frame extraction.
2026-08-03 13:13:38 +08:00

251 lines
10 KiB
TypeScript

import {
connectivityClass,
extractErrorCode,
isClientAbort,
rebuildErrorValue,
SafeError,
} from "@snapotter/shared";
import { describe, expect, it } from "vitest";
const sysErr = (code: string, syscall?: string) =>
Object.assign(new Error(`${code}: boom /Users/secret/file.png`), { code, syscall });
describe("rebuildErrorValue", () => {
it("passes SafeError messages through", () => {
expect(rebuildErrorValue(new SafeError("Storage directory is not writable"))).toBe(
"Storage directory is not writable",
);
});
it("rebuilds node system errors as code + syscall, no path", () => {
expect(rebuildErrorValue(sysErr("EACCES", "mkdir"))).toBe("EACCES mkdir");
expect(rebuildErrorValue(sysErr("ENOSPC"))).toBe("ENOSPC");
});
it("resolves a 5-char EPIPE as a node code, not a SQLSTATE", () => {
expect(rebuildErrorValue(sysErr("EPIPE", "write"))).toBe("EPIPE write");
});
it("finds a pg SQLSTATE through a drizzle-style cause chain, never the SQL", () => {
const pg = Object.assign(new Error('relation "settings" does not exist'), {
code: "42P01",
severity: "ERROR",
});
const wrapped = Object.assign(new Error('Failed query: select "value" from "settings"'), {
cause: pg,
});
expect(rebuildErrorValue(wrapped)).toBe("pg 42P01");
});
it("appends a vetted pg routine when present", () => {
const pg = Object.assign(new Error("terminating connection due to administrator command"), {
code: "57P01",
routine: "ProcessInterrupts",
});
expect(rebuildErrorValue(pg)).toBe("pg 57P01 ProcessInterrupts");
});
it("keeps only the first token of a redis ReplyError", () => {
const err = Object.assign(new Error("NOAUTH Authentication required secret-arg"), {
name: "ReplyError",
});
expect(rebuildErrorValue(err)).toBe("reply NOAUTH");
});
it("returns bare reply when the ReplyError first token is not code-shaped", () => {
const err = Object.assign(new Error("user_script:1:attempted-to-index-secret"), {
name: "ReplyError",
});
expect(rebuildErrorValue(err)).toBe("reply");
});
it("rebuilds zod errors as issue code + path", () => {
const err = Object.assign(new Error("big zod dump with received values"), {
name: "ZodError",
issues: [{ code: "invalid_type", path: ["settings", "width"] }],
});
expect(rebuildErrorValue(err)).toBe("zod invalid_type at settings.width");
});
it("replaces unvettable zod path segments with a tilde", () => {
const err = Object.assign(new Error("dump"), {
name: "ZodError",
issues: [{ code: "invalid_type", path: ["files", "user secret.pdf", 0] }],
});
expect(rebuildErrorValue(err)).toBe("zod invalid_type at files.~.0");
});
it("keeps a redacted message for an unknown Error, null only for non-objects", () => {
// Was type-only before the fallback ladder; now the message survives, redacted.
expect(rebuildErrorValue(new Error("user file /tmp/x.pdf broke"))).toBe(
"user file <path> broke",
);
expect(rebuildErrorValue("string")).toBeNull();
expect(rebuildErrorValue(null)).toBeNull();
});
it("discards hostile names on the status branch", () => {
const err = Object.assign(new Error("upstream said no"), {
name: "Bad Gateway https://internal.host/path",
status: 502,
});
expect(rebuildErrorValue(err)).toBe("HttpError 502");
});
it("keeps the redacted message for a circular cause chain without hanging", () => {
const err = new Error("loop") as Error & { cause?: unknown };
err.cause = err;
// chain()'s max cap breaks the cycle; the top message still surfaces.
expect(rebuildErrorValue(err)).toBe("loop");
});
});
describe("rebuildErrorValue ladder", () => {
it("keeps a redacted message for an unknown error (was type-only)", () => {
expect(rebuildErrorValue(new Error("Background removal failed"))).toBe(
"Background removal failed",
);
expect(rebuildErrorValue(new Error("open /data/uploads/9f/in.bin"))).toBe("open <path>");
});
it("redacts a SafeError message (NODE-4W path leak)", () => {
const e = new SafeError("[Errno 13] Permission denied: '/root/.u2net/x.onnx'", { kind: "bug" });
expect(rebuildErrorValue(e)).toBe("[Errno 13] Permission denied: '<path>'");
});
it("appends the redacted cause to a SafeError title (NODE-3D)", () => {
const cause = new Error("unsupported image format for /data/x.heic");
const e = new SafeError("Image conversion failed", { kind: "bug", cause });
expect(rebuildErrorValue(e)).toBe(
"Image conversion failed: unsupported image format for <path>",
);
});
it("derives a frame title for an empty-message error (NODE-3C)", () => {
const e = new Error("");
e.stack = "Error\n at Object.process (/app/apps/api/src/routes/tools/rounded-crop.ts:96:10)";
expect(rebuildErrorValue(e)).toBe("at rounded-crop.ts:96");
});
it("still prefers a pg SQLSTATE rebuild over the raw message", () => {
const e = Object.assign(new Error(`password authentication failed for user "x"`), {
code: "28P01",
routine: "auth_failed",
});
expect(rebuildErrorValue(e)).toBe("pg 28P01 auth_failed");
});
it("returns null only when there is no message and no stack", () => {
expect(rebuildErrorValue({ name: "Weird" })).toBeNull();
});
it("handles a pathological stack without catastrophic backtracking", () => {
const e = new Error("");
e.stack = `Error\n at x (/apps/${".".repeat(100000)})`;
const start = performance.now();
const out = rebuildErrorValue(e);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(500);
expect(out).toBeNull();
});
});
describe("extractErrorCode", () => {
it("finds a pg SQLSTATE through a drizzle-style cause chain", () => {
const pg = Object.assign(new Error("password authentication failed"), {
code: "28P01",
severity: "FATAL",
});
const wrapped = Object.assign(new Error("Failed query: select 1"), { cause: pg });
expect(extractErrorCode(wrapped)).toBe("28P01");
});
it("finds a node syscall code nested in the chain", () => {
const net = Object.assign(new Error("getaddrinfo ENOTFOUND db"), { code: "ENOTFOUND" });
const wrapped = Object.assign(new Error("connect failed"), { cause: net });
expect(extractErrorCode(wrapped)).toBe("ENOTFOUND");
});
it("prefers a pg SQLSTATE over an earlier non-standard code", () => {
const pg = Object.assign(new Error("deadlock detected"), { code: "40P01" });
const wrapped = Object.assign(new Error("wrap"), { code: "generic", cause: pg });
expect(extractErrorCode(wrapped)).toBe("40P01");
});
it("falls back to a SafeError's authored code when no pg/node code is present", () => {
const err = new SafeError("Python script timed out", { kind: "operational", code: "timeout" });
expect(extractErrorCode(err)).toBe("timeout");
});
it("returns null when no code exists anywhere in the chain", () => {
expect(extractErrorCode(new Error("plain boom"))).toBeNull();
expect(extractErrorCode("string")).toBeNull();
expect(extractErrorCode(null)).toBeNull();
});
it("returns null when a cause getter throws (hostile)", () => {
const hostile = new Error("boom");
Object.defineProperty(hostile, "cause", {
get() {
throw new Error("gotcha");
},
});
expect(extractErrorCode(hostile)).toBeNull();
});
});
describe("connectivityClass", () => {
it("classifies pg-unavailable via SQLSTATE 08/57P and drizzle wrapping", () => {
const pg = Object.assign(new Error("terminating connection"), { code: "57P01" });
expect(connectivityClass(Object.assign(new Error("Failed query: x"), { cause: pg }))).toBe(
"pg-unavailable",
);
});
it("classifies ECONN* under pg when the chain looks like pg, else net", () => {
const conn = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:5432"), {
code: "ECONNREFUSED",
});
expect(connectivityClass(Object.assign(new Error("Failed query: y"), { cause: conn }))).toBe(
"pg-unavailable",
);
expect(connectivityClass(conn)).toBe("net-unavailable");
});
it("classifies ioredis connection loss as redis-unavailable", () => {
const err = Object.assign(new Error("Connection is closed."), {
name: "MaxRetriesPerRequestError",
});
expect(connectivityClass(err)).toBe("redis-unavailable");
});
it("returns null for ordinary errors and ReplyError", () => {
expect(connectivityClass(new Error("nope"))).toBeNull();
expect(
connectivityClass(Object.assign(new Error("ERR unknown command"), { name: "ReplyError" })),
).toBeNull();
});
});
describe("isClientAbort", () => {
it("matches abort/premature-close/reset shapes", () => {
expect(isClientAbort(Object.assign(new Error("aborted"), { code: "ECONNRESET" }))).toBe(true);
expect(
isClientAbort(
Object.assign(new Error("premature close"), { code: "ERR_STREAM_PREMATURE_CLOSE" }),
),
).toBe(true);
expect(
isClientAbort(Object.assign(new Error("request aborted"), { name: "RequestAbortedError" })),
).toBe(true);
expect(isClientAbort(new Error("boom"))).toBe(false);
});
it("treats a top-level bare ECONNRESET as a client abort", () => {
const err = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
expect(isClientAbort(err)).toBe(true);
});
it("does not treat a drizzle-wrapped pg ECONNRESET as a client abort", () => {
const conn = Object.assign(new Error("connect ECONNRESET 127.0.0.1:5432"), {
code: "ECONNRESET",
});
const wrapped = Object.assign(new Error("Failed query: z"), { cause: conn });
expect(isClientAbort(wrapped)).toBe(false);
expect(connectivityClass(wrapped)).toBe("pg-unavailable");
});
});
describe("hostile error shapes", () => {
it("returns the fallback from all three exports when a cause getter throws", () => {
const hostile = new Error("boom");
Object.defineProperty(hostile, "cause", {
get() {
throw new Error("gotcha");
},
});
expect(rebuildErrorValue(hostile)).toBeNull();
expect(connectivityClass(hostile)).toBeNull();
expect(isClientAbort(hostile)).toBe(false);
});
});