feat: unit tests for security/utility functions (isPrivateIP, isTransientError, markdown, escapeHtml)
This commit is contained in:
parent
0a002f94ef
commit
50a163b12d
10 changed files with 224 additions and 62 deletions
36
src/__tests__/db-utils.test.ts
Normal file
36
src/__tests__/db-utils.test.ts
Normal file
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
18
src/__tests__/html-utils.test.ts
Normal file
18
src/__tests__/html-utils.test.ts
Normal file
|
|
@ -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<b")).toBe("a<b"));
|
||||
it("escapes >", () => 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;");
|
||||
});
|
||||
});
|
||||
50
src/__tests__/markdown.test.ts
Normal file
50
src/__tests__/markdown.test.ts
Normal file
|
|
@ -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("<h1>Hello</h1>");
|
||||
});
|
||||
it("converts bold", () => {
|
||||
expect(markdownToHtml("**bold**")).toContain("<strong>bold</strong>");
|
||||
});
|
||||
it("converts italic", () => {
|
||||
expect(markdownToHtml("*italic*")).toContain("<em>italic</em>");
|
||||
});
|
||||
it("converts links", () => {
|
||||
expect(markdownToHtml("[link](http://x.com)")).toContain('<a href="http://x.com">link</a>');
|
||||
});
|
||||
it("converts code blocks", () => {
|
||||
expect(markdownToHtml("```\ncode\n```")).toContain("<code>");
|
||||
});
|
||||
it("handles empty string", () => {
|
||||
const result = markdownToHtml("");
|
||||
expect(result).toContain("<!DOCTYPE html>");
|
||||
});
|
||||
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("<p>test</p>");
|
||||
expect(result).toContain("<!DOCTYPE html>");
|
||||
expect(result).toContain("<body><p>test</p></body>");
|
||||
});
|
||||
it("applies custom CSS", () => {
|
||||
expect(wrapHtml("<p>x</p>", "h1{color:blue}")).toContain("h1{color:blue}");
|
||||
});
|
||||
it("uses default CSS when none provided", () => {
|
||||
expect(wrapHtml("<p>x</p>")).toContain("font-family");
|
||||
});
|
||||
it("handles empty string body", () => {
|
||||
const result = wrapHtml("");
|
||||
expect(result).toContain("<body></body>");
|
||||
});
|
||||
});
|
||||
54
src/__tests__/network.test.ts
Normal file
54
src/__tests__/network.test.ts
Normal file
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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, """).replace(/'/g, "'");
|
||||
}
|
||||
import { escapeHtml } from "../utils/html.js";
|
||||
|
||||
let _stripe: Stripe | null = null;
|
||||
function getStripe(): Stripe {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
32
src/utils/errors.ts
Normal file
32
src/utils/errors.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
3
src/utils/html.ts
Normal file
3
src/utils/html.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
27
src/utils/network.ts
Normal file
27
src/utils/network.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue