mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
251 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|