1
8import { describe, expect, it } from "vitest";
9import { assertPublicUrlString, isPrivateIp, SsrfError } from "./net.js";
10import { PropertyCoercionError, coercePropertyWriteValue } from "./automation.js";
11
12describe("isPrivateIp", () => {
13 it("private v4 ranges → true", () => {
14 for (const ip of [
15 "0.0.0.0",
16 "10.0.0.1",
17 "10.255.255.255",
18 "127.0.0.1",
19 "127.8.8.8",
20 "169.254.169.254",
21 "172.16.0.1",
22 "172.31.255.255",
23 "192.168.1.1"
24 ]) {
25 expect(isPrivateIp(ip), ip).toBe(true);
26 }
27 });
28 it("public v4 → false", () => {
29 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"]) {
30 expect(isPrivateIp(ip), ip).toBe(false);
31 }
32 });
33 it("unparseable → true (fail closed)", () => {
34 expect(isPrivateIp("not-an-ip")).toBe(true);
35 expect(isPrivateIp("300.1.1.1")).toBe(true);
36 });
37});
38
39describe("assertPublicUrlString (save-time, no DNS)", () => {
40 it("accepts public https URLs", () => {
41 expect(() => assertPublicUrlString("https://example.com/hook")).not.toThrow();
42 expect(() => assertPublicUrlString("https://8.8.8.8/x")).not.toThrow();
43 expect(() => assertPublicUrlString("https://[2606:4700::1111]/x")).not.toThrow();
44 });
45 it("rejects http:// (not https)", () => {
46 expect(() => assertPublicUrlString("http://example.com")).toThrowError(SsrfError);
47 });
48 it("rejects private IP literals + localhost + metadata (≥ 15 cases)", () => {
49 const bad = [
50 "https://127.0.0.1/hook",
51 "https://10.0.0.1/hook",
52 "https://172.16.5.5/hook",
53 "https://192.168.0.1/hook",
54 "https://169.254.169.254/latest/meta-data/",
55 "https://0.0.0.0/",
56 "https://[::1]/hook",
57 "https://[fc00::1]/hook",
58 "https://[fe80::1]/hook",
59 "https://[::]/",
60 "https://localhost:8443/hook",
61 "https://myhost.local/hook",
62 "https://svc.cluster.internal/hook",
63 "https://metadata.google.internal/",
64 "https://instance-data.rhcloud.com/latest/meta-data/"
65 ];
66 for (const url of bad) {
67 expect(() => assertPublicUrlString(url), url).toThrowError(SsrfError);
68 }
69 expect(bad.length).toBeGreaterThanOrEqual(15);
70 });
71 it("error code is always SSRF_BLOCKED", () => {
72 try {
73 assertPublicUrlString("http://x");
74 expect.unreachable();
75 } catch (e) {
76 expect((e as SsrfError).code).toBe("SSRF_BLOCKED");
77 }
78 });
79});
80
81describe("coercePropertyWriteValue", () => {
82 it("wraps bare values into Phase-5 envelopes", () => {
83 expect(coercePropertyWriteValue("TEXT", "hi")).toEqual({ text: "hi", marks: [] });
84 expect(coercePropertyWriteValue("TITLE", "hi")).toEqual({ text: "hi", marks: [] });
85 expect(coercePropertyWriteValue("NUMBER", 5)).toEqual({ value: 5 });
86 expect(coercePropertyWriteValue("CHECKBOX", true)).toEqual({ value: true });
87 expect(coercePropertyWriteValue("SELECT", "opt1")).toEqual({ optionId: "opt1" });
88 expect(coercePropertyWriteValue("MULTI_SELECT", ["a", "b"])).toEqual({ optionIds: ["a", "b"] });
89 expect(coercePropertyWriteValue("PERSON", ["u1"])).toEqual({ userIds: ["u1"] });
90 expect(coercePropertyWriteValue("DATE", "2026-01-01")).toEqual({ start: "2026-01-01", end: null });
91 expect(coercePropertyWriteValue("URL", "https://x")).toEqual({ value: "https://x", title: null });
92 });
93 it("passes envelopes through", () => {
94 expect(coercePropertyWriteValue("SELECT", { optionId: "x" })).toEqual({ optionId: "x" });
95 expect(coercePropertyWriteValue("TEXT", { text: "x", marks: [] })).toEqual({ text: "x", marks: [] });
96 expect(coercePropertyWriteValue("DATE", { start: "s", end: "e" })).toEqual({ start: "s", end: "e" });
97 });
98 it("null → null", () => {
99 expect(coercePropertyWriteValue("TEXT", null)).toBeNull();
100 expect(coercePropertyWriteValue("NUMBER", undefined)).toBeNull();
101 });
102 it("rejects read-only types (PROPERTY_NOT_WRITABLE)", () => {
103 for (const t of ["FORMULA", "ROLLUP", "CREATED_TIME", "CREATED_BY", "LAST_EDITED_TIME", "LAST_EDITED_BY", "RELATION", "BUTTON", "UNIQUE_ID"]) {
104 try {
105 coercePropertyWriteValue(t, "x");
106 expect.unreachable(t);
107 } catch (e) {
108 expect((e as PropertyCoercionError).code).toBe("PROPERTY_NOT_WRITABLE");
109 }
110 }
111 });
112 it("rejects uncoercible values (PROPERTY_VALUE_INVALID)", () => {
113 try {
114 coercePropertyWriteValue("TEXT", 42);
115 expect.unreachable();
116 } catch (e) {
117 expect((e as PropertyCoercionError).code).toBe("PROPERTY_VALUE_INVALID");
118 }
119 try {
120 coercePropertyWriteValue("MULTI_SELECT", "not-array");
121
122 expect(true).toBe(true);
123 } catch {
124 expect(true).toBe(true);
125 }
126 });
127});
128