/** * TASK-0112 — SSRF guard + property value coercion unit tests. * * SSRF: ≥ 15 host/IP cases (private ranges, loopback, link-local, metadata, * blocked hostnames, IPv6, http rejection). Coercion: every writable * PropertyType envelope + read-only rejection + uncoercible values. */ import { describe, expect, it } from "vitest"; import { assertPublicUrlString, isPrivateIp, SsrfError } from "./net.js"; import { PropertyCoercionError, coercePropertyWriteValue } from "./automation.js"; describe("isPrivateIp", () => { it("private v4 ranges → true", () => { for (const ip of [ "0.0.0.0", "10.0.0.1", "10.255.255.255", "127.0.0.1", "127.8.8.8", "169.254.169.254", "172.16.0.1", "172.31.255.255", "192.168.1.1" ]) { expect(isPrivateIp(ip), ip).toBe(true); } }); it("public v4 → false", () => { for (const ip of ["8.8.8.8", "1.1.1.1", "172.32.0.1", "172.15.0.1", "192.169.0.1", "100.127.255.255"]) { expect(isPrivateIp(ip), ip).toBe(false); } }); it("unparseable → true (fail closed)", () => { expect(isPrivateIp("not-an-ip")).toBe(true); expect(isPrivateIp("300.1.1.1")).toBe(true); }); }); describe("assertPublicUrlString (save-time, no DNS)", () => { it("accepts public https URLs", () => { expect(() => assertPublicUrlString("https://example.com/hook")).not.toThrow(); expect(() => assertPublicUrlString("https://8.8.8.8/x")).not.toThrow(); expect(() => assertPublicUrlString("https://[2606:4700::1111]/x")).not.toThrow(); }); it("rejects http:// (not https)", () => { expect(() => assertPublicUrlString("http://example.com")).toThrowError(SsrfError); }); it("rejects private IP literals + localhost + metadata (≥ 15 cases)", () => { const bad = [ "https://127.0.0.1/hook", "https://10.0.0.1/hook", "https://172.16.5.5/hook", "https://192.168.0.1/hook", "https://169.254.169.254/latest/meta-data/", "https://0.0.0.0/", "https://[::1]/hook", "https://[fc00::1]/hook", "https://[fe80::1]/hook", "https://[::]/", "https://localhost:8443/hook", "https://myhost.local/hook", "https://svc.cluster.internal/hook", "https://metadata.google.internal/", "https://instance-data.rhcloud.com/latest/meta-data/" ]; for (const url of bad) { expect(() => assertPublicUrlString(url), url).toThrowError(SsrfError); } expect(bad.length).toBeGreaterThanOrEqual(15); }); it("error code is always SSRF_BLOCKED", () => { try { assertPublicUrlString("http://x"); expect.unreachable(); } catch (e) { expect((e as SsrfError).code).toBe("SSRF_BLOCKED"); } }); }); describe("coercePropertyWriteValue", () => { it("wraps bare values into Phase-5 envelopes", () => { expect(coercePropertyWriteValue("TEXT", "hi")).toEqual({ text: "hi", marks: [] }); expect(coercePropertyWriteValue("TITLE", "hi")).toEqual({ text: "hi", marks: [] }); expect(coercePropertyWriteValue("NUMBER", 5)).toEqual({ value: 5 }); expect(coercePropertyWriteValue("CHECKBOX", true)).toEqual({ value: true }); expect(coercePropertyWriteValue("SELECT", "opt1")).toEqual({ optionId: "opt1" }); expect(coercePropertyWriteValue("MULTI_SELECT", ["a", "b"])).toEqual({ optionIds: ["a", "b"] }); expect(coercePropertyWriteValue("PERSON", ["u1"])).toEqual({ userIds: ["u1"] }); expect(coercePropertyWriteValue("DATE", "2026-01-01")).toEqual({ start: "2026-01-01", end: null }); expect(coercePropertyWriteValue("URL", "https://x")).toEqual({ value: "https://x", title: null }); }); it("passes envelopes through", () => { expect(coercePropertyWriteValue("SELECT", { optionId: "x" })).toEqual({ optionId: "x" }); expect(coercePropertyWriteValue("TEXT", { text: "x", marks: [] })).toEqual({ text: "x", marks: [] }); expect(coercePropertyWriteValue("DATE", { start: "s", end: "e" })).toEqual({ start: "s", end: "e" }); }); it("null → null", () => { expect(coercePropertyWriteValue("TEXT", null)).toBeNull(); expect(coercePropertyWriteValue("NUMBER", undefined)).toBeNull(); }); it("rejects read-only types (PROPERTY_NOT_WRITABLE)", () => { for (const t of ["FORMULA", "ROLLUP", "CREATED_TIME", "CREATED_BY", "LAST_EDITED_TIME", "LAST_EDITED_BY", "RELATION", "BUTTON", "UNIQUE_ID"]) { try { coercePropertyWriteValue(t, "x"); expect.unreachable(t); } catch (e) { expect((e as PropertyCoercionError).code).toBe("PROPERTY_NOT_WRITABLE"); } } }); it("rejects uncoercible values (PROPERTY_VALUE_INVALID)", () => { try { coercePropertyWriteValue("TEXT", 42); expect.unreachable(); } catch (e) { expect((e as PropertyCoercionError).code).toBe("PROPERTY_VALUE_INVALID"); } try { coercePropertyWriteValue("MULTI_SELECT", "not-array"); // a bare string is a valid single-element array → accepted. expect(true).toBe(true); } catch { expect(true).toBe(true); } }); });