CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
integrations.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 40e7738 | 1 | /** |
| 2 | * Pure-function tests for the integrations connector layer. | |
| 3 | * Covers config validation, redaction, and per-connector renderers. | |
| 4 | * No DB calls — that surface is covered by route smoke tests downstream. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, it, expect } from "bun:test"; | |
| 8 | import { | |
| 9 | CONNECTORS, | |
| 10 | INTEGRATION_KINDS, | |
| 11 | INTEGRATION_EVENTS, | |
| 12 | __test, | |
| 13 | getConnector, | |
| 14 | isHttpUrl, | |
| 15 | isValidEvent, | |
| 16 | isValidKind, | |
| 17 | redactConfig, | |
| 18 | validateConfig, | |
| 19 | } from "../lib/integrations"; | |
| 20 | ||
| 21 | const { render, summary, hmacHex } = __test; | |
| 22 | ||
| 23 | describe("integrations — registry shape", () => { | |
| 24 | it("exposes one connector per declared kind", () => { | |
| 25 | for (const kind of INTEGRATION_KINDS) { | |
| 26 | expect(getConnector(kind)).toBeTruthy(); | |
| 27 | } | |
| 28 | }); | |
| 29 | ||
| 30 | it("validates kind + event whitelists", () => { | |
| 31 | expect(isValidKind("slack")).toBe(true); | |
| 32 | expect(isValidKind("not-a-thing")).toBe(false); | |
| 33 | expect(isValidEvent("push")).toBe(true); | |
| 34 | expect(isValidEvent("nope")).toBe(false); | |
| 35 | }); | |
| 36 | ||
| 37 | it("CONNECTORS list matches INTEGRATION_KINDS", () => { | |
| 38 | const kinds = new Set(CONNECTORS.map((c) => c.kind)); | |
| 39 | for (const k of INTEGRATION_KINDS) expect(kinds.has(k)).toBe(true); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("integrations — isHttpUrl", () => { | |
| 44 | it("accepts http(s) only", () => { | |
| 45 | expect(isHttpUrl("https://example.com")).toBe(true); | |
| 46 | expect(isHttpUrl("http://example.com")).toBe(true); | |
| 47 | expect(isHttpUrl("ftp://example.com")).toBe(false); | |
| 48 | expect(isHttpUrl("not-a-url")).toBe(false); | |
| 49 | expect(isHttpUrl("")).toBe(false); | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | describe("integrations — validateConfig", () => { | |
| 54 | it("flags missing required fields", () => { | |
| 55 | const r = validateConfig("slack", {}); | |
| 56 | expect(r.ok).toBe(false); | |
| 57 | expect(r.error).toContain("Incoming webhook URL"); | |
| 58 | }); | |
| 59 | ||
| 60 | it("flags non-URL values for URL fields", () => { | |
| 61 | const r = validateConfig("slack", { webhookUrl: "not-a-url" }); | |
| 62 | expect(r.ok).toBe(false); | |
| 63 | expect(r.error).toContain("URL"); | |
| 64 | }); | |
| 65 | ||
| 66 | it("accepts a valid Slack config", () => { | |
| 67 | const r = validateConfig("slack", { | |
| 68 | webhookUrl: "https://hooks.slack.com/services/xxx", | |
| 69 | }); | |
| 70 | expect(r.ok).toBe(true); | |
| 71 | }); | |
| 72 | ||
| 73 | it("rejects unknown kind", () => { | |
| 74 | const r = validateConfig("not-a-kind" as never, {}); | |
| 75 | expect(r.ok).toBe(false); | |
| 76 | }); | |
| 77 | ||
| 78 | it("PagerDuty requires only the routing key (no URL)", () => { | |
| 79 | expect( | |
| 80 | validateConfig("pagerduty", { integrationKey: "abc123" }).ok | |
| 81 | ).toBe(true); | |
| 82 | expect(validateConfig("pagerduty", {}).ok).toBe(false); | |
| 83 | }); | |
| 84 | }); | |
| 85 | ||
| 86 | describe("integrations — redactConfig", () => { | |
| 87 | it("redacts secret fields", () => { | |
| 88 | const out = redactConfig("slack", { | |
| 89 | webhookUrl: "https://hooks.slack.com/services/T0/B0/abc123", | |
| 90 | channel: "#eng", | |
| 91 | }); | |
| 92 | expect(typeof out.webhookUrl).toBe("string"); | |
| 93 | expect(String(out.webhookUrl)).toContain("…"); | |
| 94 | expect(out.channel).toBe("#eng"); | |
| 95 | }); | |
| 96 | ||
| 97 | it("returns short-form for very short secrets", () => { | |
| 98 | const out = redactConfig("slack", { webhookUrl: "abcd" }); | |
| 99 | expect(String(out.webhookUrl)).toBe("***"); | |
| 100 | }); | |
| 101 | ||
| 102 | it("ignores unknown fields", () => { | |
| 103 | const out = redactConfig("slack", { mystery: "value" }); | |
| 104 | expect(out.mystery).toBeUndefined(); | |
| 105 | }); | |
| 106 | }); | |
| 107 | ||
| 108 | describe("integrations — summary helper", () => { | |
| 109 | it("formats per-event human strings", () => { | |
| 110 | expect(summary("push", { repository: "a/b" })).toContain("Push to a/b"); | |
| 111 | expect(summary("pr.opened", { repository: "a/b", title: "T" })).toContain( | |
| 112 | "PR opened" | |
| 113 | ); | |
| 114 | expect(summary("deploy.failed", { repository: "a/b" })).toContain("FAILED"); | |
| 115 | }); | |
| 116 | }); | |
| 117 | ||
| 118 | describe("integrations — render Slack", () => { | |
| 119 | it("returns null on missing webhook", () => { | |
| 120 | expect( | |
| 121 | render("slack", {}, "push", { repository: "a/b" }) | |
| 122 | ).toBeNull(); | |
| 123 | }); | |
| 124 | it("renders a JSON Slack body", () => { | |
| 125 | const r = render( | |
| 126 | "slack", | |
| 127 | { webhookUrl: "https://hooks.slack.com/services/abc" }, | |
| 128 | "push", | |
| 129 | { repository: "a/b" } | |
| 130 | )!; | |
| 131 | expect(r.url).toContain("hooks.slack.com"); | |
| 132 | expect(r.headers["Content-Type"]).toBe("application/json"); | |
| 133 | expect(JSON.parse(r.body).text).toContain("Push to a/b"); | |
| 134 | }); | |
| 135 | it("includes channel override when provided", () => { | |
| 136 | const r = render( | |
| 137 | "slack", | |
| 138 | { | |
| 139 | webhookUrl: "https://hooks.slack.com/services/abc", | |
| 140 | channel: "#eng", | |
| 141 | }, | |
| 142 | "push", | |
| 143 | { repository: "a/b" } | |
| 144 | )!; | |
| 145 | expect(JSON.parse(r.body).channel).toBe("#eng"); | |
| 146 | }); | |
| 147 | }); | |
| 148 | ||
| 149 | describe("integrations — render Discord", () => { | |
| 150 | it("renders a content payload", () => { | |
| 151 | const r = render( | |
| 152 | "discord", | |
| 153 | { webhookUrl: "https://discord.com/api/webhooks/x/y" }, | |
| 154 | "pr.opened", | |
| 155 | { repository: "a/b", title: "PR" } | |
| 156 | )!; | |
| 157 | expect(JSON.parse(r.body).content).toContain("PR opened"); | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | describe("integrations — render Vercel", () => { | |
| 162 | it("only fires on push or deploy.success", () => { | |
| 163 | expect( | |
| 164 | render( | |
| 165 | "vercel", | |
| 166 | { deployHookUrl: "https://api.vercel.com/v1/integrations/deploy/x" }, | |
| 167 | "issue.opened", | |
| 168 | { repository: "a/b" } | |
| 169 | ) | |
| 170 | ).toBeNull(); | |
| 171 | const r = render( | |
| 172 | "vercel", | |
| 173 | { deployHookUrl: "https://api.vercel.com/v1/integrations/deploy/x" }, | |
| 174 | "push", | |
| 175 | { repository: "a/b" } | |
| 176 | )!; | |
| 177 | expect(r.url).toContain("vercel.com"); | |
| 178 | expect(r.body).toBe(""); | |
| 179 | }); | |
| 180 | }); | |
| 181 | ||
| 182 | describe("integrations — render PagerDuty", () => { | |
| 183 | it("only escalates failures", () => { | |
| 184 | expect( | |
| 185 | render("pagerduty", { integrationKey: "k" }, "push", { repository: "a/b" }) | |
| 186 | ).toBeNull(); | |
| 187 | const r = render("pagerduty", { integrationKey: "k" }, "deploy.failed", { | |
| 188 | repository: "a/b", | |
| 189 | })!; | |
| 190 | const body = JSON.parse(r.body); | |
| 191 | expect(body.routing_key).toBe("k"); | |
| 192 | expect(body.event_action).toBe("trigger"); | |
| 193 | expect(body.payload.severity).toBe("error"); | |
| 194 | }); | |
| 195 | it("respects user-set severity", () => { | |
| 196 | const r = render( | |
| 197 | "pagerduty", | |
| 198 | { integrationKey: "k", severity: "critical" }, | |
| 199 | "ai.incident", | |
| 200 | { repository: "a/b", title: "down" } | |
| 201 | )!; | |
| 202 | expect(JSON.parse(r.body).payload.severity).toBe("critical"); | |
| 203 | }); | |
| 204 | }); | |
| 205 | ||
| 206 | describe("integrations — render Datadog", () => { | |
| 207 | it("requires apiKey, defaults site, alert_type maps from event", () => { | |
| 208 | expect(render("datadog", {}, "push", { repository: "a/b" })).toBeNull(); | |
| 209 | const r = render("datadog", { apiKey: "k" }, "deploy.failed", { | |
| 210 | repository: "a/b", | |
| 211 | })!; | |
| 212 | expect(r.url).toContain("api.datadoghq.com"); | |
| 213 | expect(r.headers["DD-API-KEY"]).toBe("k"); | |
| 214 | expect(JSON.parse(r.body).alert_type).toBe("error"); | |
| 215 | }); | |
| 216 | }); | |
| 217 | ||
| 218 | describe("integrations — render generic_webhook", () => { | |
| 219 | it("requires a URL", () => { | |
| 220 | expect( | |
| 221 | render("generic_webhook", {}, "push", { repository: "a/b" }) | |
| 222 | ).toBeNull(); | |
| 223 | }); | |
| 224 | it("signs body when secret is set", () => { | |
| 225 | const r = render( | |
| 226 | "generic_webhook", | |
| 227 | { | |
| 228 | webhookUrl: "https://example.com/hook", | |
| 229 | secret: "shh", | |
| 230 | }, | |
| 231 | "push", | |
| 232 | { repository: "a/b" } | |
| 233 | )!; | |
| 234 | expect(r.headers["X-Gluecron-Signature"]).toContain("sha256="); | |
| 235 | }); | |
| 236 | it("omits signature without secret", () => { | |
| 237 | const r = render( | |
| 238 | "generic_webhook", | |
| 239 | { webhookUrl: "https://example.com/hook" }, | |
| 240 | "push", | |
| 241 | { repository: "a/b" } | |
| 242 | )!; | |
| 243 | expect(r.headers["X-Gluecron-Signature"]).toBeUndefined(); | |
| 244 | }); | |
| 245 | }); | |
| 246 | ||
| 247 | describe("integrations — hmacHex", () => { | |
| 248 | it("produces deterministic sha256 digests", () => { | |
| 249 | const a = hmacHex("k", "body"); | |
| 250 | const b = hmacHex("k", "body"); | |
| 251 | expect(a).toBe(b); | |
| 252 | expect(a).toMatch(/^sha256=[0-9a-f]{64}$/); | |
| 253 | }); | |
| 254 | }); | |
| 255 | ||
| 256 | describe("integrations — INTEGRATION_EVENTS shape", () => { | |
| 257 | it("includes the canonical lifecycle events", () => { | |
| 258 | for (const ev of [ | |
| 259 | "push", | |
| 260 | "pr.opened", | |
| 261 | "pr.merged", | |
| 262 | "deploy.success", | |
| 263 | "deploy.failed", | |
| 264 | "ai.repair", | |
| 265 | "ai.incident", | |
| 266 | ]) { | |
| 267 | expect(INTEGRATION_EVENTS).toContain(ev as never); | |
| 268 | } | |
| 269 | }); | |
| 270 | }); |