Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

green-ecosystem.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.

green-ecosystem.test.tsBlame404 lines · 1 contributor
3ef4c9dClaude1/**
2 * Tests for the green ecosystem: secret scanner, codeowners parser,
3 * auto-repair helpers, notification + audit log helpers, health routes,
4 * and rate limiting.
5 *
6 * These unit-level tests avoid DB + git subprocess work so they run fast.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { scanForSecrets, SECRET_PATTERNS } from "../lib/security-scan";
12import {
13 parseCodeowners,
14 ownersForPath,
15} from "../lib/codeowners";
16import { generateCommitMessage } from "../lib/ai-generators";
17import { isAiAvailable } from "../lib/ai-client";
6fc53bdClaude18import {
19 isAllowedEmoji,
20 isAllowedTarget,
21 ALLOWED_EMOJIS,
22 EMOJI_GLYPH,
23} from "../lib/reactions";
24cf2caClaude24import { sendEmail, absoluteUrl } from "../lib/email";
25import { __internal as notifyInternal } from "../lib/notify";
3ef4c9dClaude26
27describe("secret scanner", () => {
28 it("detects AWS access keys", () => {
29 const findings = scanForSecrets([
30 {
31 path: "config.env",
32 content: "AWS_ACCESS_KEY=AKIAZ2J4NPQR5LTMWXYZ\n",
33 },
34 ]);
35 expect(findings.length).toBeGreaterThan(0);
36 expect(findings.some((f) => /AWS/i.test(f.type))).toBe(true);
37 });
38
39 it("detects Anthropic API keys", () => {
40 const findings = scanForSecrets([
41 {
42 path: "app.ts",
43 content:
44 'const key = "sk-ant-api03-QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ-AAAAAA";',
45 },
46 ]);
47 expect(findings.some((f) => /anthropic/i.test(f.type))).toBe(true);
48 });
49
50 it("ignores binary/lock paths", () => {
51 const findings = scanForSecrets([
52 {
53 path: "package-lock.json",
54 content: "AKIAZ2J4NPQR5LTMWXYZ secret content",
55 },
56 ]);
57 expect(findings.length).toBe(0);
58 });
59
60 it("does not match placeholder strings in test fixtures", () => {
61 const findings = scanForSecrets([
62 {
63 path: "test.js",
64 content:
65 '// example: AKIA" + "XAMPLE_PLACEHOLDER_KEY_FIXTURE"\nconst k = "FAKE_TEST_PLACEHOLDER";',
66 },
67 ]);
68 // all findings should be filtered by the placeholder heuristic
69 expect(findings.every((f) => !/placeholder|fixture/i.test(f.snippet))).toBe(true);
70 });
71
72 it("has a rich library of rules", () => {
73 expect(SECRET_PATTERNS.length).toBeGreaterThanOrEqual(10);
74 });
75});
76
77describe("codeowners parser", () => {
78 it("parses simple rules", () => {
79 const rules = parseCodeowners(
80 "# top-level owner\n* @alice\nsrc/api/** @bob @carol\n/docs/ @alice\n"
81 );
82 expect(rules.length).toBe(3);
83 expect(rules[0].owners).toEqual(["alice"]);
84 expect(rules[1].owners).toEqual(["bob", "carol"]);
85 });
86
87 it("resolves last-matching rule wins", () => {
88 const rules = parseCodeowners("* @alice\nsrc/api/** @bob\n");
89 expect(ownersForPath("README.md", rules)).toEqual(["alice"]);
90 expect(ownersForPath("src/api/users.ts", rules)).toEqual(["bob"]);
91 });
92
93 it("anchors leading-slash patterns to repo root", () => {
94 const rules = parseCodeowners("/docs/ @alice\n");
95 expect(ownersForPath("docs/readme.md", rules)).toEqual(["alice"]);
96 expect(ownersForPath("src/docs/readme.md", rules)).toEqual([]);
97 });
98
99 it("ignores comments and blank lines", () => {
100 const rules = parseCodeowners(
101 "# comment\n\n \n# another\n* @ghost # trailing comment\n"
102 );
103 expect(rules.length).toBe(1);
104 expect(rules[0].owners).toEqual(["ghost"]);
105 });
106});
107
108describe("AI generator fallbacks", () => {
109 it("returns a safe fallback commit message when AI is unavailable", async () => {
110 if (isAiAvailable()) {
111 // API key is set — skip fallback assertion
112 return;
113 }
114 const msg = await generateCommitMessage("");
115 expect(msg.length).toBeGreaterThan(0);
116 expect(msg).toMatch(/^\S+/);
117 });
118});
119
120describe("health + metrics endpoints", () => {
121 it("GET /healthz returns 200", async () => {
122 const res = await app.request("/healthz");
123 expect(res.status).toBe(200);
124 const body = await res.json();
125 expect(body.ok).toBe(true);
126 });
127
128 it("GET /metrics returns process metrics", async () => {
129 const res = await app.request("/metrics");
130 expect(res.status).toBe(200);
131 const body = await res.json();
132 expect(typeof body.uptimeMs).toBe("number");
133 expect(typeof body.heapUsed).toBe("number");
134 });
135
136 it("response carries X-Request-Id header", async () => {
137 const res = await app.request("/healthz");
138 expect(res.headers.get("X-Request-Id")).toBeTruthy();
139 });
140});
141
142describe("rate limiting", () => {
143 it("rate-limit headers appear on /api requests", async () => {
144 const res = await app.request("/api/users/nonexistent/repos");
145 // Headers should exist even though user is missing
146 const limit = res.headers.get("X-RateLimit-Limit");
147 expect(limit).toBeTruthy();
148 });
149});
150
151describe("shortcuts + search page", () => {
152 it("GET /shortcuts is public", async () => {
153 const res = await app.request("/shortcuts");
154 expect(res.status).toBe(200);
155 const html = await res.text();
156 expect(html).toContain("Keyboard shortcuts");
157 });
158
159 it("GET /search with no query shows the type tabs", async () => {
160 const res = await app.request("/search");
161 expect(res.status).toBe(200);
162 const html = await res.text();
163 expect(html).toContain("Repositories");
164 expect(html).toContain("Users");
165 });
166});
ad6d4adClaude167
168describe("GateTest inbound hook", () => {
169 it("GET /api/hooks/ping is unauthenticated and reports service", async () => {
170 const res = await app.request("/api/hooks/ping");
171 expect(res.status).toBe(200);
172 const body = await res.json();
173 expect(body.ok).toBe(true);
174 expect(body.service).toBe("gluecron");
175 expect(Array.isArray(body.hooks)).toBe(true);
176 });
177
178 it("POST /api/hooks/gatetest rejects when no secret configured", async () => {
179 const prev = process.env.GATETEST_CALLBACK_SECRET;
180 const prevH = process.env.GATETEST_HMAC_SECRET;
181 delete process.env.GATETEST_CALLBACK_SECRET;
182 delete process.env.GATETEST_HMAC_SECRET;
183 const res = await app.request("/api/hooks/gatetest", {
184 method: "POST",
185 headers: { "content-type": "application/json" },
186 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
187 });
188 expect(res.status).toBe(401);
189 if (prev) process.env.GATETEST_CALLBACK_SECRET = prev;
190 if (prevH) process.env.GATETEST_HMAC_SECRET = prevH;
191 });
192
193 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
194 const prev = process.env.GATETEST_CALLBACK_SECRET;
195 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
196 const res = await app.request("/api/hooks/gatetest", {
197 method: "POST",
198 headers: {
199 "content-type": "application/json",
200 authorization: "Bearer wrong-token",
201 },
202 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
203 });
204 expect(res.status).toBe(401);
205 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
206 else process.env.GATETEST_CALLBACK_SECRET = prev;
207 });
208
209 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
210 const prev = process.env.GATETEST_CALLBACK_SECRET;
211 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
212 const res = await app.request("/api/hooks/gatetest", {
213 method: "POST",
214 headers: {
215 "content-type": "application/json",
216 authorization: "Bearer real-secret-abc123",
217 },
218 body: "not-json",
219 });
220 expect(res.status).toBe(400);
221 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
222 else process.env.GATETEST_CALLBACK_SECRET = prev;
223 });
224
225 it("POST /api/v1/gate-runs (backup) rejects without bearer", async () => {
226 const res = await app.request("/api/v1/gate-runs", {
227 method: "POST",
228 headers: { "content-type": "application/json" },
229 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
230 });
231 expect(res.status).toBe(401);
232 });
233});
6fc53bdClaude234
235describe("theme toggle", () => {
236 it("GET /theme/toggle sets a cookie and redirects", async () => {
237 const res = await app.request("/theme/toggle");
238 // 302 redirect; no cookie yet means we flip from the default (dark) → light
239 expect([301, 302, 303, 307]).toContain(res.status);
240 const setCookie = res.headers.get("set-cookie") || "";
241 expect(/theme=light/.test(setCookie)).toBe(true);
242 });
243
244 it("GET /theme/toggle flips an existing 'light' cookie back to dark", async () => {
245 const res = await app.request("/theme/toggle", {
246 headers: { cookie: "theme=light" },
247 });
248 const setCookie = res.headers.get("set-cookie") || "";
249 expect(/theme=dark/.test(setCookie)).toBe(true);
250 });
251
252 it("GET /theme/set?mode=light returns JSON when asked", async () => {
253 const res = await app.request("/theme/set?mode=light", {
254 headers: { accept: "application/json" },
255 });
256 expect(res.status).toBe(200);
257 const body = await res.json();
258 expect(body.ok).toBe(true);
259 expect(body.theme).toBe("light");
260 });
261
262 it("GET /theme/set rejects unknown modes", async () => {
263 const res = await app.request("/theme/set?mode=neon", {
264 headers: { accept: "application/json" },
265 });
266 expect(res.status).toBe(400);
267 });
268
269 it("home page includes the pre-paint theme script + data-theme attribute", async () => {
270 const res = await app.request("/");
271 const html = await res.text();
272 expect(html).toContain("data-theme");
273 expect(html).toContain("theme-icon-");
274 // The pre-paint script reads the cookie.
275 expect(html).toContain("document.cookie");
276 });
277});
278
279describe("reactions", () => {
280 it("allowed emojis and targets are self-consistent", () => {
281 expect(ALLOWED_EMOJIS.length).toBeGreaterThanOrEqual(6);
282 for (const e of ALLOWED_EMOJIS) {
283 expect(isAllowedEmoji(e)).toBe(true);
284 expect(EMOJI_GLYPH[e]).toBeTruthy();
285 }
286 expect(isAllowedEmoji("nope")).toBe(false);
287 expect(isAllowedTarget("issue")).toBe(true);
288 expect(isAllowedTarget("martian")).toBe(false);
289 });
290
291 it("POST /api/reactions/.../toggle requires auth", async () => {
292 const res = await app.request(
293 "/api/reactions/issue/00000000-0000-0000-0000-000000000000/thumbs_up/toggle",
294 { method: "POST" }
295 );
296 // Unauthenticated -> redirect to /login (302)
297 expect([301, 302, 303, 307]).toContain(res.status);
298 });
299
300 it("GET /api/reactions/:type/:id returns empty summary when no reactions exist", async () => {
301 const res = await app.request(
302 "/api/reactions/issue/00000000-0000-0000-0000-000000000000"
303 );
304 expect(res.status).toBe(200);
305 const body = await res.json();
306 expect(body.ok).toBe(true);
307 expect(Array.isArray(body.reactions)).toBe(true);
308 });
309
310 it("rejects unknown target type on the listing endpoint", async () => {
311 const res = await app.request(
312 "/api/reactions/martian/00000000-0000-0000-0000-000000000000"
313 );
314 expect(res.status).toBe(400);
315 });
316});
317
318describe("audit log UI", () => {
319 it("GET /settings/audit redirects unauthenticated users to /login", async () => {
320 const res = await app.request("/settings/audit");
321 expect([301, 302, 303, 307]).toContain(res.status);
322 const loc = res.headers.get("location") || "";
323 expect(loc.startsWith("/login")).toBe(true);
324 });
325});
24cf2caClaude326
327describe("email", () => {
328 it("sendEmail in log mode never throws and returns ok", async () => {
329 const prev = process.env.EMAIL_PROVIDER;
330 process.env.EMAIL_PROVIDER = "log";
331 const res = await sendEmail({
332 to: "test@gluecron.local",
333 subject: "hello",
334 text: "body",
335 });
336 expect(res.ok).toBe(true);
337 expect(res.provider).toBe("log");
338 if (prev === undefined) delete process.env.EMAIL_PROVIDER;
339 else process.env.EMAIL_PROVIDER = prev;
340 });
341
342 it("sendEmail rejects invalid recipient without throwing", async () => {
343 const res = await sendEmail({
344 to: "not-an-email",
345 subject: "x",
346 text: "y",
347 });
348 expect(res.ok).toBe(false);
349 expect(res.skipped).toBeTruthy();
350 });
351
352 it("sendEmail rejects empty subject/body without throwing", async () => {
353 const res = await sendEmail({ to: "a@b.co", subject: "", text: "" });
354 expect(res.ok).toBe(false);
355 });
356
357 it("absoluteUrl joins paths against APP_BASE_URL", () => {
358 const prev = process.env.APP_BASE_URL;
359 process.env.APP_BASE_URL = "https://gluecron.example/";
360 expect(absoluteUrl("/x")).toBe("https://gluecron.example/x");
361 expect(absoluteUrl("x")).toBe("https://gluecron.example/x");
362 expect(absoluteUrl("https://other/y")).toBe("https://other/y");
363 if (prev === undefined) delete process.env.APP_BASE_URL;
364 else process.env.APP_BASE_URL = prev;
365 });
366
367 it("notify email-eligible set only includes user-opt-in kinds", () => {
368 // Any kind in EMAIL_ELIGIBLE must map to a preference column
369 for (const k of notifyInternal.EMAIL_ELIGIBLE) {
370 expect(notifyInternal.prefFor(k)).not.toBeNull();
371 }
372 // gate_passed is not eligible (too spammy; only gate_failed is)
373 expect(notifyInternal.EMAIL_ELIGIBLE.has("gate_passed" as any)).toBe(false);
374 expect(notifyInternal.EMAIL_ELIGIBLE.has("deploy_failed" as any)).toBe(
375 false
376 );
377 });
378
379 it("notify email subject is tagged and truncated", () => {
380 const subj = notifyInternal.subjectFor("gate_failed", "x".repeat(300));
381 expect(subj.startsWith("[gate failed]")).toBe(true);
382 expect(subj.length).toBeLessThanOrEqual(180);
383 });
384});
385
386describe("settings email preferences", () => {
387 it("GET /settings redirects unauthenticated users to /login", async () => {
388 const res = await app.request("/settings");
389 expect([301, 302, 303, 307]).toContain(res.status);
390 const loc = res.headers.get("location") || "";
391 expect(loc.startsWith("/login")).toBe(true);
392 });
393
394 it("POST /settings/notifications redirects unauthenticated users to /login", async () => {
395 const res = await app.request("/settings/notifications", {
396 method: "POST",
397 headers: { "content-type": "application/x-www-form-urlencoded" },
398 body: "notify_email_on_mention=1",
399 });
400 expect([301, 302, 303, 307]).toContain(res.status);
401 const loc = res.headers.get("location") || "";
402 expect(loc.startsWith("/login")).toBe(true);
403 });
404});