diff --git a/src/__tests__/db-utils.test.ts b/src/__tests__/db-utils.test.ts new file mode 100644 index 0000000..ca8323f --- /dev/null +++ b/src/__tests__/db-utils.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { isTransientError } from "../utils/errors.js"; + +describe("isTransientError", () => { + describe("transient error codes", () => { + for (const code of ["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "CONNECTION_LOST", "57P01", "57P02", "57P03", "08006", "08003", "08001"]) { + it(`detects code ${code}`, () => { + expect(isTransientError({ code })).toBe(true); + }); + } + }); + + describe("message-based matches", () => { + it("detects 'no available server'", () => expect(isTransientError(new Error("no available server found"))).toBe(true)); + it("detects 'connection terminated'", () => expect(isTransientError(new Error("connection terminated unexpectedly"))).toBe(true)); + it("detects 'connection refused'", () => expect(isTransientError(new Error("connection refused by host"))).toBe(true)); + it("detects 'server closed the connection'", () => expect(isTransientError(new Error("server closed the connection"))).toBe(true)); + it("detects 'timeout expired'", () => expect(isTransientError(new Error("timeout expired waiting"))).toBe(true)); + }); + + describe("non-transient errors", () => { + it("rejects generic error", () => expect(isTransientError(new Error("something broke"))).toBe(false)); + it("rejects SQL syntax error", () => expect(isTransientError({ code: "42601", message: "syntax error" })).toBe(false)); + }); + + describe("null/undefined input", () => { + it("returns false for null", () => expect(isTransientError(null)).toBe(false)); + it("returns false for undefined", () => expect(isTransientError(undefined)).toBe(false)); + }); + + describe("partial error objects", () => { + it("handles error with code but no message", () => expect(isTransientError({ code: "ECONNRESET" })).toBe(true)); + it("handles error with message but no code", () => expect(isTransientError({ message: "connection terminated" })).toBe(true)); + it("rejects error with unrelated code and no message", () => expect(isTransientError({ code: "UNKNOWN" })).toBe(false)); + }); +}); diff --git a/src/__tests__/html-utils.test.ts b/src/__tests__/html-utils.test.ts new file mode 100644 index 0000000..fd9a088 --- /dev/null +++ b/src/__tests__/html-utils.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { escapeHtml } from "../utils/html.js"; + +describe("escapeHtml", () => { + it("escapes &", () => expect(escapeHtml("a&b")).toBe("a&b")); + it("escapes <", () => expect(escapeHtml("a", () => expect(escapeHtml("a>b")).toBe("a>b")); + it('escapes "', () => expect(escapeHtml('a"b')).toBe("a"b")); + it("escapes '", () => expect(escapeHtml("a'b")).toBe("a'b")); + it("passes through normal text", () => expect(escapeHtml("hello world")).toBe("hello world")); + it("handles empty string", () => expect(escapeHtml("")).toBe("")); + it("escapes all special chars together", () => { + expect(escapeHtml(`&<>"'`)).toBe("&<>"'"); + }); + it("double-escapes already-escaped entities", () => { + expect(escapeHtml("&")).toBe("&amp;"); + }); +}); diff --git a/src/__tests__/markdown.test.ts b/src/__tests__/markdown.test.ts new file mode 100644 index 0000000..dc6c6ee --- /dev/null +++ b/src/__tests__/markdown.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { markdownToHtml, wrapHtml } from "../services/markdown.js"; + +describe("markdownToHtml", () => { + it("converts headings", () => { + expect(markdownToHtml("# Hello")).toContain("

Hello

"); + }); + it("converts bold", () => { + expect(markdownToHtml("**bold**")).toContain("bold"); + }); + it("converts italic", () => { + expect(markdownToHtml("*italic*")).toContain("italic"); + }); + it("converts links", () => { + expect(markdownToHtml("[link](http://x.com)")).toContain('link'); + }); + it("converts code blocks", () => { + expect(markdownToHtml("```\ncode\n```")).toContain(""); + }); + it("handles empty string", () => { + const result = markdownToHtml(""); + expect(result).toContain(""); + }); + it("applies custom CSS", () => { + const result = markdownToHtml("# Hi", "body{color:red}"); + expect(result).toContain("body{color:red}"); + }); + it("uses default CSS when none provided", () => { + const result = markdownToHtml("# Hi"); + expect(result).toContain("font-family"); + }); +}); + +describe("wrapHtml", () => { + it("wraps body in HTML document structure", () => { + const result = wrapHtml("

test

"); + expect(result).toContain(""); + expect(result).toContain("

test

"); + }); + it("applies custom CSS", () => { + expect(wrapHtml("

x

", "h1{color:blue}")).toContain("h1{color:blue}"); + }); + it("uses default CSS when none provided", () => { + expect(wrapHtml("

x

")).toContain("font-family"); + }); + it("handles empty string body", () => { + const result = wrapHtml(""); + expect(result).toContain(""); + }); +}); diff --git a/src/__tests__/network.test.ts b/src/__tests__/network.test.ts new file mode 100644 index 0000000..49b6360 --- /dev/null +++ b/src/__tests__/network.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { isPrivateIP } from "../utils/network.js"; + +describe("isPrivateIP", () => { + describe("IPv4 private ranges", () => { + it("detects 10.x.x.x as private", () => { + expect(isPrivateIP("10.0.0.1")).toBe(true); + expect(isPrivateIP("10.255.255.255")).toBe(true); + }); + it("detects 172.16-31.x.x as private", () => { + expect(isPrivateIP("172.16.0.1")).toBe(true); + expect(isPrivateIP("172.31.255.255")).toBe(true); + }); + it("detects 192.168.x.x as private", () => { + expect(isPrivateIP("192.168.0.1")).toBe(true); + expect(isPrivateIP("192.168.255.255")).toBe(true); + }); + it("detects 127.x.x.x (loopback) as private", () => { + expect(isPrivateIP("127.0.0.1")).toBe(true); + expect(isPrivateIP("127.255.255.255")).toBe(true); + }); + it("detects 0.0.0.0 as private", () => { + expect(isPrivateIP("0.0.0.0")).toBe(true); + }); + it("detects 169.254.x.x (link-local) as private", () => { + expect(isPrivateIP("169.254.1.1")).toBe(true); + }); + }); + + describe("IPv4 public addresses", () => { + it("allows 8.8.8.8", () => expect(isPrivateIP("8.8.8.8")).toBe(false)); + it("allows 1.1.1.1", () => expect(isPrivateIP("1.1.1.1")).toBe(false)); + it("allows 172.15.0.1 (just below range)", () => expect(isPrivateIP("172.15.0.1")).toBe(false)); + it("allows 172.32.0.1 (just above range)", () => expect(isPrivateIP("172.32.0.1")).toBe(false)); + }); + + describe("IPv6", () => { + it("detects ::1 (loopback) as private", () => expect(isPrivateIP("::1")).toBe(true)); + it("detects :: (unspecified) as private", () => expect(isPrivateIP("::")).toBe(true)); + it("detects fe80::1 (link-local) as private", () => expect(isPrivateIP("fe80::1")).toBe(true)); + it("detects fc00::1 (unique local) as private", () => expect(isPrivateIP("fc00::1")).toBe(true)); + it("detects fd00::1 (unique local) as private", () => expect(isPrivateIP("fd00::1")).toBe(true)); + }); + + describe("IPv4-mapped IPv6", () => { + it("detects ::ffff:10.0.0.1 as private", () => expect(isPrivateIP("::ffff:10.0.0.1")).toBe(true)); + it("allows ::ffff:8.8.8.8 as public", () => expect(isPrivateIP("::ffff:8.8.8.8")).toBe(false)); + }); + + describe("edge cases", () => { + it("returns false for empty string", () => expect(isPrivateIP("")).toBe(false)); + it("returns false for random text", () => expect(isPrivateIP("not-an-ip")).toBe(false)); + }); +}); diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 4e9c94a..091689d 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -4,9 +4,7 @@ import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js"; import logger from "../services/logger.js"; -function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); -} +import { escapeHtml } from "../utils/html.js"; let _stripe: Stripe | null = null; function getStripe(): Stripe { diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 6330b1c..bdfb3eb 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -3,33 +3,7 @@ import { renderPdf, renderUrlPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import dns from "node:dns/promises"; import logger from "../services/logger.js"; -import net from "node:net"; - -function isPrivateIP(ip: string): boolean { - // IPv6 loopback/unspecified - if (ip === "::1" || ip === "::") return true; - - // IPv6 link-local (fe80::/10) - if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") || - ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb")) return true; - - // IPv6 unique local (fc00::/7) - const lower = ip.toLowerCase(); - if (lower.startsWith("fc") || lower.startsWith("fd")) return true; - - // IPv4-mapped IPv6 - if (ip.startsWith("::ffff:")) ip = ip.slice(7); - if (!net.isIPv4(ip)) return false; - - const parts = ip.split(".").map(Number); - if (parts[0] === 0) return true; // 0.0.0.0/8 - if (parts[0] === 10) return true; // 10.0.0.0/8 - if (parts[0] === 127) return true; // 127.0.0.0/8 - if (parts[0] === 169 && parts[1] === 254) return true; // 169.254.0.0/16 - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12 - if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16 - return false; -} +import { isPrivateIP } from "../utils/network.js"; import { sanitizeFilename } from "../utils/sanitize.js"; diff --git a/src/services/db.ts b/src/services/db.ts index 123bdc7..947f204 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -1,23 +1,9 @@ import pg from "pg"; import logger from "./logger.js"; +import { isTransientError } from "../utils/errors.js"; const { Pool } = pg; -// Transient error codes from PgBouncer / PostgreSQL that warrant retry -const TRANSIENT_ERRORS = new Set([ - "ECONNRESET", - "ECONNREFUSED", - "EPIPE", - "ETIMEDOUT", - "CONNECTION_LOST", - "57P01", // admin_shutdown - "57P02", // crash_shutdown - "57P03", // cannot_connect_now - "08006", // connection_failure - "08003", // connection_does_not_exist - "08001", // sqlclient_unable_to_establish_sqlconnection -]); - const pool = new Pool({ host: process.env.DATABASE_HOST || "172.17.0.1", port: parseInt(process.env.DATABASE_PORT || "5432", 10), @@ -38,23 +24,7 @@ pool.on("error", (err, client) => { logger.error({ err }, "Unexpected error on idle PostgreSQL client — evicted from pool"); }); -/** - * Determine if an error is transient (PgBouncer failover, network blip) - */ -export function isTransientError(err: any): boolean { - if (!err) return false; - const code = err.code || ""; - const msg = (err.message || "").toLowerCase(); - - if (TRANSIENT_ERRORS.has(code)) return true; - if (msg.includes("no available server")) return true; // PgBouncer specific - if (msg.includes("connection terminated")) return true; - if (msg.includes("connection refused")) return true; - if (msg.includes("server closed the connection")) return true; - if (msg.includes("timeout expired")) return true; - - return false; -} +export { isTransientError } from "../utils/errors.js"; /** * Execute a query with automatic retry on transient errors. diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..b31cb9c --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,32 @@ +// Transient error codes from PgBouncer / PostgreSQL that warrant retry +const TRANSIENT_ERRORS = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "EPIPE", + "ETIMEDOUT", + "CONNECTION_LOST", + "57P01", // admin_shutdown + "57P02", // crash_shutdown + "57P03", // cannot_connect_now + "08006", // connection_failure + "08003", // connection_does_not_exist + "08001", // sqlclient_unable_to_establish_sqlconnection +]); + +/** + * Determine if an error is transient (PgBouncer failover, network blip) + */ +export function isTransientError(err: any): boolean { + if (!err) return false; + const code = err.code || ""; + const msg = (err.message || "").toLowerCase(); + + if (TRANSIENT_ERRORS.has(code)) return true; + if (msg.includes("no available server")) return true; + if (msg.includes("connection terminated")) return true; + if (msg.includes("connection refused")) return true; + if (msg.includes("server closed the connection")) return true; + if (msg.includes("timeout expired")) return true; + + return false; +} diff --git a/src/utils/html.ts b/src/utils/html.ts new file mode 100644 index 0000000..e25499d --- /dev/null +++ b/src/utils/html.ts @@ -0,0 +1,3 @@ +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} diff --git a/src/utils/network.ts b/src/utils/network.ts new file mode 100644 index 0000000..6bd53a1 --- /dev/null +++ b/src/utils/network.ts @@ -0,0 +1,27 @@ +import net from "node:net"; + +export function isPrivateIP(ip: string): boolean { + // IPv6 loopback/unspecified + if (ip === "::1" || ip === "::") return true; + + // IPv6 link-local (fe80::/10) + if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") || + ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb")) return true; + + // IPv6 unique local (fc00::/7) + const lower = ip.toLowerCase(); + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + + // IPv4-mapped IPv6 + if (ip.startsWith("::ffff:")) ip = ip.slice(7); + if (!net.isIPv4(ip)) return false; + + const parts = ip.split(".").map(Number); + if (parts[0] === 0) return true; // 0.0.0.0/8 + if (parts[0] === 10) return true; // 10.0.0.0/8 + if (parts[0] === 127) return true; // 127.0.0.0/8 + if (parts[0] === 169 && parts[1] === 254) return true; // 169.254.0.0/16 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12 + if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16 + return false; +}