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.tsBlame603 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";
6563f0aClaude26import {
27 isValidSlug,
28 normalizeSlug,
29 orgRoleAtLeast,
30 isValidOrgRole,
31 isValidTeamRole,
32 __test as orgsInternal,
33} from "../lib/orgs";
3ef4c9dClaude34
35describe("secret scanner", () => {
36 it("detects AWS access keys", () => {
37 const findings = scanForSecrets([
38 {
39 path: "config.env",
40 content: "AWS_ACCESS_KEY=AKIAZ2J4NPQR5LTMWXYZ\n",
41 },
42 ]);
43 expect(findings.length).toBeGreaterThan(0);
44 expect(findings.some((f) => /AWS/i.test(f.type))).toBe(true);
45 });
46
47 it("detects Anthropic API keys", () => {
48 const findings = scanForSecrets([
49 {
50 path: "app.ts",
51 content:
52 'const key = "sk-ant-api03-QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ-AAAAAA";',
53 },
54 ]);
55 expect(findings.some((f) => /anthropic/i.test(f.type))).toBe(true);
56 });
57
58 it("ignores binary/lock paths", () => {
59 const findings = scanForSecrets([
60 {
61 path: "package-lock.json",
62 content: "AKIAZ2J4NPQR5LTMWXYZ secret content",
63 },
64 ]);
65 expect(findings.length).toBe(0);
66 });
67
68 it("does not match placeholder strings in test fixtures", () => {
69 const findings = scanForSecrets([
70 {
71 path: "test.js",
72 content:
73 '// example: AKIA" + "XAMPLE_PLACEHOLDER_KEY_FIXTURE"\nconst k = "FAKE_TEST_PLACEHOLDER";',
74 },
75 ]);
76 // all findings should be filtered by the placeholder heuristic
77 expect(findings.every((f) => !/placeholder|fixture/i.test(f.snippet))).toBe(true);
78 });
79
80 it("has a rich library of rules", () => {
81 expect(SECRET_PATTERNS.length).toBeGreaterThanOrEqual(10);
82 });
83});
84
85describe("codeowners parser", () => {
86 it("parses simple rules", () => {
87 const rules = parseCodeowners(
88 "# top-level owner\n* @alice\nsrc/api/** @bob @carol\n/docs/ @alice\n"
89 );
90 expect(rules.length).toBe(3);
91 expect(rules[0].owners).toEqual(["alice"]);
92 expect(rules[1].owners).toEqual(["bob", "carol"]);
93 });
94
95 it("resolves last-matching rule wins", () => {
96 const rules = parseCodeowners("* @alice\nsrc/api/** @bob\n");
97 expect(ownersForPath("README.md", rules)).toEqual(["alice"]);
98 expect(ownersForPath("src/api/users.ts", rules)).toEqual(["bob"]);
99 });
100
101 it("anchors leading-slash patterns to repo root", () => {
102 const rules = parseCodeowners("/docs/ @alice\n");
103 expect(ownersForPath("docs/readme.md", rules)).toEqual(["alice"]);
104 expect(ownersForPath("src/docs/readme.md", rules)).toEqual([]);
105 });
106
107 it("ignores comments and blank lines", () => {
108 const rules = parseCodeowners(
109 "# comment\n\n \n# another\n* @ghost # trailing comment\n"
110 );
111 expect(rules.length).toBe(1);
112 expect(rules[0].owners).toEqual(["ghost"]);
113 });
114});
115
116describe("AI generator fallbacks", () => {
117 it("returns a safe fallback commit message when AI is unavailable", async () => {
118 if (isAiAvailable()) {
119 // API key is set — skip fallback assertion
120 return;
121 }
122 const msg = await generateCommitMessage("");
123 expect(msg.length).toBeGreaterThan(0);
124 expect(msg).toMatch(/^\S+/);
125 });
126});
127
128describe("health + metrics endpoints", () => {
129 it("GET /healthz returns 200", async () => {
130 const res = await app.request("/healthz");
131 expect(res.status).toBe(200);
132 const body = await res.json();
133 expect(body.ok).toBe(true);
134 });
135
136 it("GET /metrics returns process metrics", async () => {
137 const res = await app.request("/metrics");
138 expect(res.status).toBe(200);
139 const body = await res.json();
140 expect(typeof body.uptimeMs).toBe("number");
141 expect(typeof body.heapUsed).toBe("number");
142 });
143
144 it("response carries X-Request-Id header", async () => {
145 const res = await app.request("/healthz");
146 expect(res.headers.get("X-Request-Id")).toBeTruthy();
147 });
148});
149
150describe("rate limiting", () => {
151 it("rate-limit headers appear on /api requests", async () => {
152 const res = await app.request("/api/users/nonexistent/repos");
153 // Headers should exist even though user is missing
154 const limit = res.headers.get("X-RateLimit-Limit");
155 expect(limit).toBeTruthy();
156 });
157});
158
159describe("shortcuts + search page", () => {
160 it("GET /shortcuts is public", async () => {
161 const res = await app.request("/shortcuts");
162 expect(res.status).toBe(200);
163 const html = await res.text();
164 expect(html).toContain("Keyboard shortcuts");
165 });
166
167 it("GET /search with no query shows the type tabs", async () => {
168 const res = await app.request("/search");
169 expect(res.status).toBe(200);
170 const html = await res.text();
171 expect(html).toContain("Repositories");
172 expect(html).toContain("Users");
173 });
174});
ad6d4adClaude175
176describe("GateTest inbound hook", () => {
177 it("GET /api/hooks/ping is unauthenticated and reports service", async () => {
178 const res = await app.request("/api/hooks/ping");
179 expect(res.status).toBe(200);
180 const body = await res.json();
181 expect(body.ok).toBe(true);
182 expect(body.service).toBe("gluecron");
183 expect(Array.isArray(body.hooks)).toBe(true);
184 });
185
186 it("POST /api/hooks/gatetest rejects when no secret configured", async () => {
187 const prev = process.env.GATETEST_CALLBACK_SECRET;
188 const prevH = process.env.GATETEST_HMAC_SECRET;
189 delete process.env.GATETEST_CALLBACK_SECRET;
190 delete process.env.GATETEST_HMAC_SECRET;
191 const res = await app.request("/api/hooks/gatetest", {
192 method: "POST",
193 headers: { "content-type": "application/json" },
194 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
195 });
196 expect(res.status).toBe(401);
197 if (prev) process.env.GATETEST_CALLBACK_SECRET = prev;
198 if (prevH) process.env.GATETEST_HMAC_SECRET = prevH;
199 });
200
201 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
202 const prev = process.env.GATETEST_CALLBACK_SECRET;
203 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
204 const res = await app.request("/api/hooks/gatetest", {
205 method: "POST",
206 headers: {
207 "content-type": "application/json",
208 authorization: "Bearer wrong-token",
209 },
210 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
211 });
212 expect(res.status).toBe(401);
213 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
214 else process.env.GATETEST_CALLBACK_SECRET = prev;
215 });
216
217 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
218 const prev = process.env.GATETEST_CALLBACK_SECRET;
219 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
220 const res = await app.request("/api/hooks/gatetest", {
221 method: "POST",
222 headers: {
223 "content-type": "application/json",
224 authorization: "Bearer real-secret-abc123",
225 },
226 body: "not-json",
227 });
228 expect(res.status).toBe(400);
229 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
230 else process.env.GATETEST_CALLBACK_SECRET = prev;
231 });
232
233 it("POST /api/v1/gate-runs (backup) rejects without bearer", async () => {
234 const res = await app.request("/api/v1/gate-runs", {
235 method: "POST",
236 headers: { "content-type": "application/json" },
237 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
238 });
239 expect(res.status).toBe(401);
240 });
241});
6fc53bdClaude242
243describe("theme toggle", () => {
244 it("GET /theme/toggle sets a cookie and redirects", async () => {
245 const res = await app.request("/theme/toggle");
246 // 302 redirect; no cookie yet means we flip from the default (dark) → light
247 expect([301, 302, 303, 307]).toContain(res.status);
248 const setCookie = res.headers.get("set-cookie") || "";
249 expect(/theme=light/.test(setCookie)).toBe(true);
250 });
251
252 it("GET /theme/toggle flips an existing 'light' cookie back to dark", async () => {
253 const res = await app.request("/theme/toggle", {
254 headers: { cookie: "theme=light" },
255 });
256 const setCookie = res.headers.get("set-cookie") || "";
257 expect(/theme=dark/.test(setCookie)).toBe(true);
258 });
259
260 it("GET /theme/set?mode=light returns JSON when asked", async () => {
261 const res = await app.request("/theme/set?mode=light", {
262 headers: { accept: "application/json" },
263 });
264 expect(res.status).toBe(200);
265 const body = await res.json();
266 expect(body.ok).toBe(true);
267 expect(body.theme).toBe("light");
268 });
269
270 it("GET /theme/set rejects unknown modes", async () => {
271 const res = await app.request("/theme/set?mode=neon", {
272 headers: { accept: "application/json" },
273 });
274 expect(res.status).toBe(400);
275 });
276
277 it("home page includes the pre-paint theme script + data-theme attribute", async () => {
278 const res = await app.request("/");
279 const html = await res.text();
280 expect(html).toContain("data-theme");
281 expect(html).toContain("theme-icon-");
282 // The pre-paint script reads the cookie.
283 expect(html).toContain("document.cookie");
284 });
285});
286
287describe("reactions", () => {
288 it("allowed emojis and targets are self-consistent", () => {
289 expect(ALLOWED_EMOJIS.length).toBeGreaterThanOrEqual(6);
290 for (const e of ALLOWED_EMOJIS) {
291 expect(isAllowedEmoji(e)).toBe(true);
292 expect(EMOJI_GLYPH[e]).toBeTruthy();
293 }
294 expect(isAllowedEmoji("nope")).toBe(false);
295 expect(isAllowedTarget("issue")).toBe(true);
296 expect(isAllowedTarget("martian")).toBe(false);
297 });
298
299 it("POST /api/reactions/.../toggle requires auth", async () => {
300 const res = await app.request(
301 "/api/reactions/issue/00000000-0000-0000-0000-000000000000/thumbs_up/toggle",
302 { method: "POST" }
303 );
304 // Unauthenticated -> redirect to /login (302)
305 expect([301, 302, 303, 307]).toContain(res.status);
306 });
307
308 it("GET /api/reactions/:type/:id returns empty summary when no reactions exist", async () => {
309 const res = await app.request(
310 "/api/reactions/issue/00000000-0000-0000-0000-000000000000"
311 );
312 expect(res.status).toBe(200);
313 const body = await res.json();
314 expect(body.ok).toBe(true);
315 expect(Array.isArray(body.reactions)).toBe(true);
316 });
317
318 it("rejects unknown target type on the listing endpoint", async () => {
319 const res = await app.request(
320 "/api/reactions/martian/00000000-0000-0000-0000-000000000000"
321 );
322 expect(res.status).toBe(400);
323 });
324});
325
326describe("audit log UI", () => {
327 it("GET /settings/audit redirects unauthenticated users to /login", async () => {
328 const res = await app.request("/settings/audit");
329 expect([301, 302, 303, 307]).toContain(res.status);
330 const loc = res.headers.get("location") || "";
331 expect(loc.startsWith("/login")).toBe(true);
332 });
333});
24cf2caClaude334
335describe("email", () => {
336 it("sendEmail in log mode never throws and returns ok", async () => {
337 const prev = process.env.EMAIL_PROVIDER;
338 process.env.EMAIL_PROVIDER = "log";
339 const res = await sendEmail({
340 to: "test@gluecron.local",
341 subject: "hello",
342 text: "body",
343 });
344 expect(res.ok).toBe(true);
345 expect(res.provider).toBe("log");
346 if (prev === undefined) delete process.env.EMAIL_PROVIDER;
347 else process.env.EMAIL_PROVIDER = prev;
348 });
349
350 it("sendEmail rejects invalid recipient without throwing", async () => {
351 const res = await sendEmail({
352 to: "not-an-email",
353 subject: "x",
354 text: "y",
355 });
356 expect(res.ok).toBe(false);
357 expect(res.skipped).toBeTruthy();
358 });
359
360 it("sendEmail rejects empty subject/body without throwing", async () => {
361 const res = await sendEmail({ to: "a@b.co", subject: "", text: "" });
362 expect(res.ok).toBe(false);
363 });
364
365 it("absoluteUrl joins paths against APP_BASE_URL", () => {
366 const prev = process.env.APP_BASE_URL;
367 process.env.APP_BASE_URL = "https://gluecron.example/";
368 expect(absoluteUrl("/x")).toBe("https://gluecron.example/x");
369 expect(absoluteUrl("x")).toBe("https://gluecron.example/x");
370 expect(absoluteUrl("https://other/y")).toBe("https://other/y");
371 if (prev === undefined) delete process.env.APP_BASE_URL;
372 else process.env.APP_BASE_URL = prev;
373 });
374
375 it("notify email-eligible set only includes user-opt-in kinds", () => {
376 // Any kind in EMAIL_ELIGIBLE must map to a preference column
377 for (const k of notifyInternal.EMAIL_ELIGIBLE) {
378 expect(notifyInternal.prefFor(k)).not.toBeNull();
379 }
380 // gate_passed is not eligible (too spammy; only gate_failed is)
381 expect(notifyInternal.EMAIL_ELIGIBLE.has("gate_passed" as any)).toBe(false);
382 expect(notifyInternal.EMAIL_ELIGIBLE.has("deploy_failed" as any)).toBe(
383 false
384 );
385 });
386
387 it("notify email subject is tagged and truncated", () => {
388 const subj = notifyInternal.subjectFor("gate_failed", "x".repeat(300));
389 expect(subj.startsWith("[gate failed]")).toBe(true);
390 expect(subj.length).toBeLessThanOrEqual(180);
391 });
392});
393
394describe("settings email preferences", () => {
395 it("GET /settings redirects unauthenticated users to /login", async () => {
396 const res = await app.request("/settings");
397 expect([301, 302, 303, 307]).toContain(res.status);
398 const loc = res.headers.get("location") || "";
399 expect(loc.startsWith("/login")).toBe(true);
400 });
401
402 it("POST /settings/notifications redirects unauthenticated users to /login", async () => {
403 const res = await app.request("/settings/notifications", {
404 method: "POST",
405 headers: { "content-type": "application/x-www-form-urlencoded" },
406 body: "notify_email_on_mention=1",
407 });
408 expect([301, 302, 303, 307]).toContain(res.status);
409 const loc = res.headers.get("location") || "";
410 expect(loc.startsWith("/login")).toBe(true);
411 });
412});
6563f0aClaude413
414describe("orgs helpers (B1)", () => {
415 describe("isValidSlug", () => {
416 it("accepts simple slugs", () => {
417 expect(isValidSlug("acme")).toBe(true);
418 expect(isValidSlug("acme-corp")).toBe(true);
419 expect(isValidSlug("a1")).toBe(true);
420 expect(isValidSlug("a-b-c-1-2-3")).toBe(true);
421 });
422
423 it("rejects too-short or too-long", () => {
424 expect(isValidSlug("")).toBe(false);
425 expect(isValidSlug("a")).toBe(false);
426 expect(isValidSlug("a".repeat(40))).toBe(false);
427 });
428
429 it("rejects leading/trailing hyphen", () => {
430 expect(isValidSlug("-acme")).toBe(false);
431 expect(isValidSlug("acme-")).toBe(false);
432 });
433
434 it("rejects consecutive hyphens", () => {
435 expect(isValidSlug("foo--bar")).toBe(false);
436 });
437
438 it("rejects uppercase + invalid chars", () => {
439 expect(isValidSlug("Acme")).toBe(false);
440 expect(isValidSlug("acme_corp")).toBe(false);
441 expect(isValidSlug("acme.corp")).toBe(false);
442 expect(isValidSlug("acme corp")).toBe(false);
443 });
444
445 it("rejects reserved words", () => {
446 expect(isValidSlug("api")).toBe(false);
447 expect(isValidSlug("admin")).toBe(false);
448 expect(isValidSlug("settings")).toBe(false);
449 expect(isValidSlug("orgs")).toBe(false);
450 expect(isValidSlug("new")).toBe(false);
451 });
452 });
453
454 describe("normalizeSlug", () => {
455 it("lowercases and trims", () => {
456 expect(normalizeSlug(" ACME ")).toBe("acme");
457 expect(normalizeSlug("Acme-Corp")).toBe("acme-corp");
458 });
459 });
460
461 describe("orgRoleAtLeast", () => {
462 it("owner beats admin beats member", () => {
463 expect(orgRoleAtLeast("owner", "member")).toBe(true);
464 expect(orgRoleAtLeast("owner", "admin")).toBe(true);
465 expect(orgRoleAtLeast("owner", "owner")).toBe(true);
466 expect(orgRoleAtLeast("admin", "member")).toBe(true);
467 expect(orgRoleAtLeast("admin", "admin")).toBe(true);
468 expect(orgRoleAtLeast("admin", "owner")).toBe(false);
469 expect(orgRoleAtLeast("member", "admin")).toBe(false);
470 expect(orgRoleAtLeast("member", "owner")).toBe(false);
471 });
472
473 it("treats unknown role as rank 0", () => {
474 expect(orgRoleAtLeast("", "member")).toBe(false);
475 expect(orgRoleAtLeast("banana", "member")).toBe(false);
476 });
477 });
478
479 describe("role type guards", () => {
480 it("isValidOrgRole", () => {
481 expect(isValidOrgRole("owner")).toBe(true);
482 expect(isValidOrgRole("admin")).toBe(true);
483 expect(isValidOrgRole("member")).toBe(true);
484 expect(isValidOrgRole("maintainer")).toBe(false);
485 expect(isValidOrgRole("banana")).toBe(false);
486 });
487
488 it("isValidTeamRole", () => {
489 expect(isValidTeamRole("maintainer")).toBe(true);
490 expect(isValidTeamRole("member")).toBe(true);
491 expect(isValidTeamRole("owner")).toBe(false);
492 expect(isValidTeamRole("banana")).toBe(false);
493 });
494 });
495
496 describe("internal", () => {
497 it("rank table orders correctly", () => {
498 const r = orgsInternal.ORG_ROLE_RANK;
499 expect(r.owner).toBeGreaterThan(r.admin);
500 expect(r.admin).toBeGreaterThan(r.member);
501 });
502
503 it("reserved set contains the app's top-level paths", () => {
504 expect(orgsInternal.RESERVED_SLUGS.has("api")).toBe(true);
505 expect(orgsInternal.RESERVED_SLUGS.has("settings")).toBe(true);
506 expect(orgsInternal.RESERVED_SLUGS.has("login")).toBe(true);
507 });
508 });
509});
510
511describe("orgs routes (B1)", () => {
512 it("GET /orgs redirects unauthenticated users to /login", async () => {
513 const res = await app.request("/orgs");
514 expect([301, 302, 303, 307]).toContain(res.status);
515 const loc = res.headers.get("location") || "";
516 expect(loc.startsWith("/login")).toBe(true);
517 });
518
519 it("GET /orgs/new redirects unauthenticated users to /login", async () => {
520 const res = await app.request("/orgs/new");
521 expect([301, 302, 303, 307]).toContain(res.status);
522 const loc = res.headers.get("location") || "";
523 expect(loc.startsWith("/login")).toBe(true);
524 });
525
526 it("POST /orgs/new redirects unauthenticated users to /login", async () => {
527 const res = await app.request("/orgs/new", {
528 method: "POST",
529 headers: { "content-type": "application/x-www-form-urlencoded" },
530 body: "slug=acme&name=Acme",
531 });
532 expect([301, 302, 303, 307]).toContain(res.status);
533 const loc = res.headers.get("location") || "";
534 expect(loc.startsWith("/login")).toBe(true);
535 });
536
537 it("GET /orgs/:slug redirects unauthenticated users to /login", async () => {
538 const res = await app.request("/orgs/some-org");
539 expect([301, 302, 303, 307]).toContain(res.status);
540 const loc = res.headers.get("location") || "";
541 expect(loc.startsWith("/login")).toBe(true);
542 });
543
544 it("POST /orgs/:slug/people/add redirects unauthenticated users to /login", async () => {
545 const res = await app.request("/orgs/some-org/people/add", {
546 method: "POST",
547 headers: { "content-type": "application/x-www-form-urlencoded" },
548 body: "username=alice&role=member",
549 });
550 expect([301, 302, 303, 307]).toContain(res.status);
551 const loc = res.headers.get("location") || "";
552 expect(loc.startsWith("/login")).toBe(true);
553 });
554});
7437605Claude555
556describe("org-owned repos (B2)", () => {
557 it("GET /orgs/:slug/repos redirects unauthenticated users to /login", async () => {
558 const res = await app.request("/orgs/some-org/repos");
559 expect([301, 302, 303, 307]).toContain(res.status);
560 const loc = res.headers.get("location") || "";
561 expect(loc.startsWith("/login")).toBe(true);
562 });
563
564 it("GET /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
565 const res = await app.request("/orgs/some-org/repos/new");
566 expect([301, 302, 303, 307]).toContain(res.status);
567 const loc = res.headers.get("location") || "";
568 expect(loc.startsWith("/login")).toBe(true);
569 });
570
571 it("POST /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
572 const res = await app.request("/orgs/some-org/repos/new", {
573 method: "POST",
574 headers: { "content-type": "application/x-www-form-urlencoded" },
575 body: "name=web",
576 });
577 expect([301, 302, 303, 307]).toContain(res.status);
578 const loc = res.headers.get("location") || "";
579 expect(loc.startsWith("/login")).toBe(true);
580 });
581
582 it("POST /api/repos with orgSlug still validates required fields", async () => {
583 const res = await app.request("/api/repos", {
584 method: "POST",
585 headers: { "Content-Type": "application/json" },
586 body: JSON.stringify({ orgSlug: "acme" }),
587 });
588 // Missing name + owner → 400 before any DB access.
589 expect(res.status).toBe(400);
590 });
591
592 it("POST /api/repos rejects invalid repo names before DB access", async () => {
593 const res = await app.request("/api/repos", {
594 method: "POST",
595 headers: { "Content-Type": "application/json" },
596 body: JSON.stringify({
597 name: "bad name with spaces",
598 owner: "alice",
599 }),
600 });
601 expect(res.status).toBe(400);
602 });
603});