Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

integrations.test.tsBlame270 lines · 1 contributor
40e7738Claude1/**
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
7import { describe, it, expect } from "bun:test";
8import {
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
21const { render, summary, hmacHex } = __test;
22
23describe("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
43describe("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
53describe("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
86describe("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
108describe("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
118describe("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
149describe("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
161describe("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
182describe("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
206describe("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
218describe("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
247describe("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
256describe("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});