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.tsBlame323 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";
3ef4c9dClaude24
25describe("secret scanner", () => {
26 it("detects AWS access keys", () => {
27 const findings = scanForSecrets([
28 {
29 path: "config.env",
30 content: "AWS_ACCESS_KEY=AKIAZ2J4NPQR5LTMWXYZ\n",
31 },
32 ]);
33 expect(findings.length).toBeGreaterThan(0);
34 expect(findings.some((f) => /AWS/i.test(f.type))).toBe(true);
35 });
36
37 it("detects Anthropic API keys", () => {
38 const findings = scanForSecrets([
39 {
40 path: "app.ts",
41 content:
42 'const key = "sk-ant-api03-QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ-AAAAAA";',
43 },
44 ]);
45 expect(findings.some((f) => /anthropic/i.test(f.type))).toBe(true);
46 });
47
48 it("ignores binary/lock paths", () => {
49 const findings = scanForSecrets([
50 {
51 path: "package-lock.json",
52 content: "AKIAZ2J4NPQR5LTMWXYZ secret content",
53 },
54 ]);
55 expect(findings.length).toBe(0);
56 });
57
58 it("does not match placeholder strings in test fixtures", () => {
59 const findings = scanForSecrets([
60 {
61 path: "test.js",
62 content:
63 '// example: AKIA" + "XAMPLE_PLACEHOLDER_KEY_FIXTURE"\nconst k = "FAKE_TEST_PLACEHOLDER";',
64 },
65 ]);
66 // all findings should be filtered by the placeholder heuristic
67 expect(findings.every((f) => !/placeholder|fixture/i.test(f.snippet))).toBe(true);
68 });
69
70 it("has a rich library of rules", () => {
71 expect(SECRET_PATTERNS.length).toBeGreaterThanOrEqual(10);
72 });
73});
74
75describe("codeowners parser", () => {
76 it("parses simple rules", () => {
77 const rules = parseCodeowners(
78 "# top-level owner\n* @alice\nsrc/api/** @bob @carol\n/docs/ @alice\n"
79 );
80 expect(rules.length).toBe(3);
81 expect(rules[0].owners).toEqual(["alice"]);
82 expect(rules[1].owners).toEqual(["bob", "carol"]);
83 });
84
85 it("resolves last-matching rule wins", () => {
86 const rules = parseCodeowners("* @alice\nsrc/api/** @bob\n");
87 expect(ownersForPath("README.md", rules)).toEqual(["alice"]);
88 expect(ownersForPath("src/api/users.ts", rules)).toEqual(["bob"]);
89 });
90
91 it("anchors leading-slash patterns to repo root", () => {
92 const rules = parseCodeowners("/docs/ @alice\n");
93 expect(ownersForPath("docs/readme.md", rules)).toEqual(["alice"]);
94 expect(ownersForPath("src/docs/readme.md", rules)).toEqual([]);
95 });
96
97 it("ignores comments and blank lines", () => {
98 const rules = parseCodeowners(
99 "# comment\n\n \n# another\n* @ghost # trailing comment\n"
100 );
101 expect(rules.length).toBe(1);
102 expect(rules[0].owners).toEqual(["ghost"]);
103 });
104});
105
106describe("AI generator fallbacks", () => {
107 it("returns a safe fallback commit message when AI is unavailable", async () => {
108 if (isAiAvailable()) {
109 // API key is set — skip fallback assertion
110 return;
111 }
112 const msg = await generateCommitMessage("");
113 expect(msg.length).toBeGreaterThan(0);
114 expect(msg).toMatch(/^\S+/);
115 });
116});
117
118describe("health + metrics endpoints", () => {
119 it("GET /healthz returns 200", async () => {
120 const res = await app.request("/healthz");
121 expect(res.status).toBe(200);
122 const body = await res.json();
123 expect(body.ok).toBe(true);
124 });
125
126 it("GET /metrics returns process metrics", async () => {
127 const res = await app.request("/metrics");
128 expect(res.status).toBe(200);
129 const body = await res.json();
130 expect(typeof body.uptimeMs).toBe("number");
131 expect(typeof body.heapUsed).toBe("number");
132 });
133
134 it("response carries X-Request-Id header", async () => {
135 const res = await app.request("/healthz");
136 expect(res.headers.get("X-Request-Id")).toBeTruthy();
137 });
138});
139
140describe("rate limiting", () => {
141 it("rate-limit headers appear on /api requests", async () => {
142 const res = await app.request("/api/users/nonexistent/repos");
143 // Headers should exist even though user is missing
144 const limit = res.headers.get("X-RateLimit-Limit");
145 expect(limit).toBeTruthy();
146 });
147});
148
149describe("shortcuts + search page", () => {
150 it("GET /shortcuts is public", async () => {
151 const res = await app.request("/shortcuts");
152 expect(res.status).toBe(200);
153 const html = await res.text();
154 expect(html).toContain("Keyboard shortcuts");
155 });
156
157 it("GET /search with no query shows the type tabs", async () => {
158 const res = await app.request("/search");
159 expect(res.status).toBe(200);
160 const html = await res.text();
161 expect(html).toContain("Repositories");
162 expect(html).toContain("Users");
163 });
164});
ad6d4adClaude165
166describe("GateTest inbound hook", () => {
167 it("GET /api/hooks/ping is unauthenticated and reports service", async () => {
168 const res = await app.request("/api/hooks/ping");
169 expect(res.status).toBe(200);
170 const body = await res.json();
171 expect(body.ok).toBe(true);
172 expect(body.service).toBe("gluecron");
173 expect(Array.isArray(body.hooks)).toBe(true);
174 });
175
176 it("POST /api/hooks/gatetest rejects when no secret configured", async () => {
177 const prev = process.env.GATETEST_CALLBACK_SECRET;
178 const prevH = process.env.GATETEST_HMAC_SECRET;
179 delete process.env.GATETEST_CALLBACK_SECRET;
180 delete process.env.GATETEST_HMAC_SECRET;
181 const res = await app.request("/api/hooks/gatetest", {
182 method: "POST",
183 headers: { "content-type": "application/json" },
184 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
185 });
186 expect(res.status).toBe(401);
187 if (prev) process.env.GATETEST_CALLBACK_SECRET = prev;
188 if (prevH) process.env.GATETEST_HMAC_SECRET = prevH;
189 });
190
191 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
192 const prev = process.env.GATETEST_CALLBACK_SECRET;
193 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
194 const res = await app.request("/api/hooks/gatetest", {
195 method: "POST",
196 headers: {
197 "content-type": "application/json",
198 authorization: "Bearer wrong-token",
199 },
200 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
201 });
202 expect(res.status).toBe(401);
203 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
204 else process.env.GATETEST_CALLBACK_SECRET = prev;
205 });
206
207 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
208 const prev = process.env.GATETEST_CALLBACK_SECRET;
209 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
210 const res = await app.request("/api/hooks/gatetest", {
211 method: "POST",
212 headers: {
213 "content-type": "application/json",
214 authorization: "Bearer real-secret-abc123",
215 },
216 body: "not-json",
217 });
218 expect(res.status).toBe(400);
219 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
220 else process.env.GATETEST_CALLBACK_SECRET = prev;
221 });
222
223 it("POST /api/v1/gate-runs (backup) rejects without bearer", async () => {
224 const res = await app.request("/api/v1/gate-runs", {
225 method: "POST",
226 headers: { "content-type": "application/json" },
227 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
228 });
229 expect(res.status).toBe(401);
230 });
231});
6fc53bdClaude232
233describe("theme toggle", () => {
234 it("GET /theme/toggle sets a cookie and redirects", async () => {
235 const res = await app.request("/theme/toggle");
236 // 302 redirect; no cookie yet means we flip from the default (dark) → light
237 expect([301, 302, 303, 307]).toContain(res.status);
238 const setCookie = res.headers.get("set-cookie") || "";
239 expect(/theme=light/.test(setCookie)).toBe(true);
240 });
241
242 it("GET /theme/toggle flips an existing 'light' cookie back to dark", async () => {
243 const res = await app.request("/theme/toggle", {
244 headers: { cookie: "theme=light" },
245 });
246 const setCookie = res.headers.get("set-cookie") || "";
247 expect(/theme=dark/.test(setCookie)).toBe(true);
248 });
249
250 it("GET /theme/set?mode=light returns JSON when asked", async () => {
251 const res = await app.request("/theme/set?mode=light", {
252 headers: { accept: "application/json" },
253 });
254 expect(res.status).toBe(200);
255 const body = await res.json();
256 expect(body.ok).toBe(true);
257 expect(body.theme).toBe("light");
258 });
259
260 it("GET /theme/set rejects unknown modes", async () => {
261 const res = await app.request("/theme/set?mode=neon", {
262 headers: { accept: "application/json" },
263 });
264 expect(res.status).toBe(400);
265 });
266
267 it("home page includes the pre-paint theme script + data-theme attribute", async () => {
268 const res = await app.request("/");
269 const html = await res.text();
270 expect(html).toContain("data-theme");
271 expect(html).toContain("theme-icon-");
272 // The pre-paint script reads the cookie.
273 expect(html).toContain("document.cookie");
274 });
275});
276
277describe("reactions", () => {
278 it("allowed emojis and targets are self-consistent", () => {
279 expect(ALLOWED_EMOJIS.length).toBeGreaterThanOrEqual(6);
280 for (const e of ALLOWED_EMOJIS) {
281 expect(isAllowedEmoji(e)).toBe(true);
282 expect(EMOJI_GLYPH[e]).toBeTruthy();
283 }
284 expect(isAllowedEmoji("nope")).toBe(false);
285 expect(isAllowedTarget("issue")).toBe(true);
286 expect(isAllowedTarget("martian")).toBe(false);
287 });
288
289 it("POST /api/reactions/.../toggle requires auth", async () => {
290 const res = await app.request(
291 "/api/reactions/issue/00000000-0000-0000-0000-000000000000/thumbs_up/toggle",
292 { method: "POST" }
293 );
294 // Unauthenticated -> redirect to /login (302)
295 expect([301, 302, 303, 307]).toContain(res.status);
296 });
297
298 it("GET /api/reactions/:type/:id returns empty summary when no reactions exist", async () => {
299 const res = await app.request(
300 "/api/reactions/issue/00000000-0000-0000-0000-000000000000"
301 );
302 expect(res.status).toBe(200);
303 const body = await res.json();
304 expect(body.ok).toBe(true);
305 expect(Array.isArray(body.reactions)).toBe(true);
306 });
307
308 it("rejects unknown target type on the listing endpoint", async () => {
309 const res = await app.request(
310 "/api/reactions/martian/00000000-0000-0000-0000-000000000000"
311 );
312 expect(res.status).toBe(400);
313 });
314});
315
316describe("audit log UI", () => {
317 it("GET /settings/audit redirects unauthenticated users to /login", async () => {
318 const res = await app.request("/settings/audit");
319 expect([301, 302, 303, 307]).toContain(res.status);
320 const loc = res.headers.get("location") || "";
321 expect(loc.startsWith("/login")).toBe(true);
322 });
323});