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

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.tsBlame799 lines · 3 contributors
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,
40d3e3fClaude15 isTeamToken,
16 expandOwnerTokens,
3ef4c9dClaude17} from "../lib/codeowners";
18import { generateCommitMessage } from "../lib/ai-generators";
19import { isAiAvailable } from "../lib/ai-client";
6fc53bdClaude20import {
21 isAllowedEmoji,
22 isAllowedTarget,
23 ALLOWED_EMOJIS,
24 EMOJI_GLYPH,
25} from "../lib/reactions";
24cf2caClaude26import { sendEmail, absoluteUrl } from "../lib/email";
27import { __internal as notifyInternal } from "../lib/notify";
6563f0aClaude28import {
29 isValidSlug,
30 normalizeSlug,
31 orgRoleAtLeast,
32 isValidOrgRole,
33 isValidTeamRole,
34 __test as orgsInternal,
35} from "../lib/orgs";
7298a17Claude36import {
37 base32Encode,
38 base32Decode,
39 generateTotpSecret,
40 totpCode,
41 verifyTotpCode,
42 otpauthUrl,
43 generateRecoveryCodes,
44 hashRecoveryCode,
45} from "../lib/totp";
3ef4c9dClaude46
47describe("secret scanner", () => {
48 it("detects AWS access keys", () => {
49 const findings = scanForSecrets([
50 {
51 path: "config.env",
52 content: "AWS_ACCESS_KEY=AKIAZ2J4NPQR5LTMWXYZ\n",
53 },
54 ]);
55 expect(findings.length).toBeGreaterThan(0);
56 expect(findings.some((f) => /AWS/i.test(f.type))).toBe(true);
57 });
58
59 it("detects Anthropic API keys", () => {
60 const findings = scanForSecrets([
61 {
62 path: "app.ts",
63 content:
64 'const key = "sk-ant-api03-QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ-AAAAAA";',
65 },
66 ]);
67 expect(findings.some((f) => /anthropic/i.test(f.type))).toBe(true);
68 });
69
70 it("ignores binary/lock paths", () => {
71 const findings = scanForSecrets([
72 {
73 path: "package-lock.json",
74 content: "AKIAZ2J4NPQR5LTMWXYZ secret content",
75 },
76 ]);
77 expect(findings.length).toBe(0);
78 });
79
80 it("does not match placeholder strings in test fixtures", () => {
81 const findings = scanForSecrets([
82 {
83 path: "test.js",
84 content:
85 '// example: AKIA" + "XAMPLE_PLACEHOLDER_KEY_FIXTURE"\nconst k = "FAKE_TEST_PLACEHOLDER";',
86 },
87 ]);
88 // all findings should be filtered by the placeholder heuristic
89 expect(findings.every((f) => !/placeholder|fixture/i.test(f.snippet))).toBe(true);
90 });
91
92 it("has a rich library of rules", () => {
93 expect(SECRET_PATTERNS.length).toBeGreaterThanOrEqual(10);
94 });
95});
96
97describe("codeowners parser", () => {
98 it("parses simple rules", () => {
99 const rules = parseCodeowners(
100 "# top-level owner\n* @alice\nsrc/api/** @bob @carol\n/docs/ @alice\n"
101 );
102 expect(rules.length).toBe(3);
103 expect(rules[0].owners).toEqual(["alice"]);
104 expect(rules[1].owners).toEqual(["bob", "carol"]);
105 });
106
107 it("resolves last-matching rule wins", () => {
108 const rules = parseCodeowners("* @alice\nsrc/api/** @bob\n");
109 expect(ownersForPath("README.md", rules)).toEqual(["alice"]);
110 expect(ownersForPath("src/api/users.ts", rules)).toEqual(["bob"]);
111 });
112
113 it("anchors leading-slash patterns to repo root", () => {
114 const rules = parseCodeowners("/docs/ @alice\n");
115 expect(ownersForPath("docs/readme.md", rules)).toEqual(["alice"]);
116 expect(ownersForPath("src/docs/readme.md", rules)).toEqual([]);
117 });
118
119 it("ignores comments and blank lines", () => {
120 const rules = parseCodeowners(
121 "# comment\n\n \n# another\n* @ghost # trailing comment\n"
122 );
123 expect(rules.length).toBe(1);
124 expect(rules[0].owners).toEqual(["ghost"]);
125 });
40d3e3fClaude126
127 it("preserves @org/team tokens (B3)", () => {
128 const rules = parseCodeowners(
129 "api/** @acme/backend @alice\nweb/** @acme/frontend\n"
130 );
131 expect(rules[0].owners).toEqual(["acme/backend", "alice"]);
132 expect(rules[1].owners).toEqual(["acme/frontend"]);
133 expect(isTeamToken("acme/backend")).toBe(true);
134 expect(isTeamToken("alice")).toBe(false);
135 });
136
137 it("expandOwnerTokens passes plain usernames through and drops unknown teams gracefully", async () => {
138 // Real team lookup requires DB rows. Without DB the helper must still
139 // resolve without throwing; plain usernames must always pass through.
140 const result = await expandOwnerTokens([
141 "alice",
142 "bob",
143 "nonexistent-org-xyz/some-team",
144 ]);
145 expect(result).toContain("alice");
146 expect(result).toContain("bob");
147 // Unknown team silently drops (no throw, no crash).
148 expect(result).not.toContain("nonexistent-org-xyz/some-team");
149 });
3ef4c9dClaude150});
151
152describe("AI generator fallbacks", () => {
153 it("returns a safe fallback commit message when AI is unavailable", async () => {
154 if (isAiAvailable()) {
155 // API key is set — skip fallback assertion
156 return;
157 }
158 const msg = await generateCommitMessage("");
159 expect(msg.length).toBeGreaterThan(0);
160 expect(msg).toMatch(/^\S+/);
161 });
162});
163
164describe("health + metrics endpoints", () => {
165 it("GET /healthz returns 200", async () => {
166 const res = await app.request("/healthz");
167 expect(res.status).toBe(200);
168 const body = await res.json();
169 expect(body.ok).toBe(true);
170 });
171
172 it("GET /metrics returns process metrics", async () => {
173 const res = await app.request("/metrics");
174 expect(res.status).toBe(200);
175 const body = await res.json();
176 expect(typeof body.uptimeMs).toBe("number");
177 expect(typeof body.heapUsed).toBe("number");
178 });
179
180 it("response carries X-Request-Id header", async () => {
181 const res = await app.request("/healthz");
182 expect(res.headers.get("X-Request-Id")).toBeTruthy();
183 });
184});
185
186describe("rate limiting", () => {
187 it("rate-limit headers appear on /api requests", async () => {
188 const res = await app.request("/api/users/nonexistent/repos");
189 // Headers should exist even though user is missing
190 const limit = res.headers.get("X-RateLimit-Limit");
191 expect(limit).toBeTruthy();
192 });
193});
194
195describe("shortcuts + search page", () => {
196 it("GET /shortcuts is public", async () => {
197 const res = await app.request("/shortcuts");
198 expect(res.status).toBe(200);
199 const html = await res.text();
200 expect(html).toContain("Keyboard shortcuts");
201 });
202
203 it("GET /search with no query shows the type tabs", async () => {
204 const res = await app.request("/search");
205 expect(res.status).toBe(200);
206 const html = await res.text();
207 expect(html).toContain("Repositories");
208 expect(html).toContain("Users");
209 });
210});
ad6d4adClaude211
212describe("GateTest inbound hook", () => {
213 it("GET /api/hooks/ping is unauthenticated and reports service", async () => {
214 const res = await app.request("/api/hooks/ping");
215 expect(res.status).toBe(200);
216 const body = await res.json();
217 expect(body.ok).toBe(true);
218 expect(body.service).toBe("gluecron");
219 expect(Array.isArray(body.hooks)).toBe(true);
220 });
221
222 it("POST /api/hooks/gatetest rejects when no secret configured", async () => {
223 const prev = process.env.GATETEST_CALLBACK_SECRET;
224 const prevH = process.env.GATETEST_HMAC_SECRET;
225 delete process.env.GATETEST_CALLBACK_SECRET;
226 delete process.env.GATETEST_HMAC_SECRET;
227 const res = await app.request("/api/hooks/gatetest", {
228 method: "POST",
229 headers: { "content-type": "application/json" },
230 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
231 });
232 expect(res.status).toBe(401);
233 if (prev) process.env.GATETEST_CALLBACK_SECRET = prev;
234 if (prevH) process.env.GATETEST_HMAC_SECRET = prevH;
235 });
236
237 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
238 const prev = process.env.GATETEST_CALLBACK_SECRET;
ea52715copilot-swe-agent[bot]239 process.env.GATETEST_CALLBACK_SECRET = "gatetest-cb-fixture";
ad6d4adClaude240 const res = await app.request("/api/hooks/gatetest", {
241 method: "POST",
242 headers: {
243 "content-type": "application/json",
244 authorization: "Bearer wrong-token",
245 },
246 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
247 });
248 expect(res.status).toBe(401);
249 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
250 else process.env.GATETEST_CALLBACK_SECRET = prev;
251 });
252
253 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
254 const prev = process.env.GATETEST_CALLBACK_SECRET;
ea52715copilot-swe-agent[bot]255 process.env.GATETEST_CALLBACK_SECRET = "gatetest-cb-fixture";
ad6d4adClaude256 const res = await app.request("/api/hooks/gatetest", {
257 method: "POST",
258 headers: {
259 "content-type": "application/json",
ea52715copilot-swe-agent[bot]260 authorization: "Bearer gatetest-cb-fixture",
ad6d4adClaude261 },
262 body: "not-json",
263 });
264 expect(res.status).toBe(400);
265 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
266 else process.env.GATETEST_CALLBACK_SECRET = prev;
267 });
268
269 it("POST /api/v1/gate-runs (backup) rejects without bearer", async () => {
270 const res = await app.request("/api/v1/gate-runs", {
271 method: "POST",
272 headers: { "content-type": "application/json" },
273 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
274 });
275 expect(res.status).toBe(401);
276 });
277});
6fc53bdClaude278
279describe("theme toggle", () => {
280 it("GET /theme/toggle sets a cookie and redirects", async () => {
281 const res = await app.request("/theme/toggle");
eb1cdf2ccanty labs282 // 302 redirect; the default is now LIGHT (readTheme defaults to light to
283 // match the SSR + pre-paint default), so the first toggle flips light → dark.
6fc53bdClaude284 expect([301, 302, 303, 307]).toContain(res.status);
285 const setCookie = res.headers.get("set-cookie") || "";
eb1cdf2ccanty labs286 expect(/theme=dark/.test(setCookie)).toBe(true);
6fc53bdClaude287 });
288
289 it("GET /theme/toggle flips an existing 'light' cookie back to dark", async () => {
290 const res = await app.request("/theme/toggle", {
291 headers: { cookie: "theme=light" },
292 });
293 const setCookie = res.headers.get("set-cookie") || "";
294 expect(/theme=dark/.test(setCookie)).toBe(true);
295 });
296
297 it("GET /theme/set?mode=light returns JSON when asked", async () => {
298 const res = await app.request("/theme/set?mode=light", {
299 headers: { accept: "application/json" },
300 });
301 expect(res.status).toBe(200);
302 const body = await res.json();
303 expect(body.ok).toBe(true);
304 expect(body.theme).toBe("light");
305 });
306
307 it("GET /theme/set rejects unknown modes", async () => {
308 const res = await app.request("/theme/set?mode=neon", {
309 headers: { accept: "application/json" },
310 });
311 expect(res.status).toBe(400);
312 });
313
74a8784Claude314 it("Layout pages include the pre-paint theme script + data-theme attribute", async () => {
315 // 2026-06-10: "/" now serves the self-contained Landing2030Page;
316 // the Layout theme contract is asserted on /help instead.
317 const res = await app.request("/help");
6fc53bdClaude318 const html = await res.text();
319 expect(html).toContain("data-theme");
320 expect(html).toContain("theme-icon-");
321 // The pre-paint script reads the cookie.
322 expect(html).toContain("document.cookie");
323 });
324});
325
326describe("reactions", () => {
327 it("allowed emojis and targets are self-consistent", () => {
328 expect(ALLOWED_EMOJIS.length).toBeGreaterThanOrEqual(6);
329 for (const e of ALLOWED_EMOJIS) {
330 expect(isAllowedEmoji(e)).toBe(true);
331 expect(EMOJI_GLYPH[e]).toBeTruthy();
332 }
333 expect(isAllowedEmoji("nope")).toBe(false);
334 expect(isAllowedTarget("issue")).toBe(true);
335 expect(isAllowedTarget("martian")).toBe(false);
336 });
337
338 it("POST /api/reactions/.../toggle requires auth", async () => {
339 const res = await app.request(
340 "/api/reactions/issue/00000000-0000-0000-0000-000000000000/thumbs_up/toggle",
341 { method: "POST" }
342 );
343 // Unauthenticated -> redirect to /login (302)
344 expect([301, 302, 303, 307]).toContain(res.status);
345 });
346
347 it("GET /api/reactions/:type/:id returns empty summary when no reactions exist", async () => {
348 const res = await app.request(
349 "/api/reactions/issue/00000000-0000-0000-0000-000000000000"
350 );
351 expect(res.status).toBe(200);
352 const body = await res.json();
353 expect(body.ok).toBe(true);
354 expect(Array.isArray(body.reactions)).toBe(true);
355 });
356
357 it("rejects unknown target type on the listing endpoint", async () => {
358 const res = await app.request(
359 "/api/reactions/martian/00000000-0000-0000-0000-000000000000"
360 );
361 expect(res.status).toBe(400);
362 });
363});
364
365describe("audit log UI", () => {
366 it("GET /settings/audit redirects unauthenticated users to /login", async () => {
367 const res = await app.request("/settings/audit");
368 expect([301, 302, 303, 307]).toContain(res.status);
369 const loc = res.headers.get("location") || "";
370 expect(loc.startsWith("/login")).toBe(true);
371 });
372});
24cf2caClaude373
374describe("email", () => {
375 it("sendEmail in log mode never throws and returns ok", async () => {
376 const prev = process.env.EMAIL_PROVIDER;
377 process.env.EMAIL_PROVIDER = "log";
378 const res = await sendEmail({
379 to: "test@gluecron.local",
380 subject: "hello",
381 text: "body",
382 });
383 expect(res.ok).toBe(true);
384 expect(res.provider).toBe("log");
385 if (prev === undefined) delete process.env.EMAIL_PROVIDER;
386 else process.env.EMAIL_PROVIDER = prev;
387 });
388
389 it("sendEmail rejects invalid recipient without throwing", async () => {
390 const res = await sendEmail({
391 to: "not-an-email",
392 subject: "x",
393 text: "y",
394 });
395 expect(res.ok).toBe(false);
396 expect(res.skipped).toBeTruthy();
397 });
398
399 it("sendEmail rejects empty subject/body without throwing", async () => {
400 const res = await sendEmail({ to: "a@b.co", subject: "", text: "" });
401 expect(res.ok).toBe(false);
402 });
403
404 it("absoluteUrl joins paths against APP_BASE_URL", () => {
405 const prev = process.env.APP_BASE_URL;
406 process.env.APP_BASE_URL = "https://gluecron.example/";
407 expect(absoluteUrl("/x")).toBe("https://gluecron.example/x");
408 expect(absoluteUrl("x")).toBe("https://gluecron.example/x");
409 expect(absoluteUrl("https://other/y")).toBe("https://other/y");
410 if (prev === undefined) delete process.env.APP_BASE_URL;
411 else process.env.APP_BASE_URL = prev;
412 });
413
414 it("notify email-eligible set only includes user-opt-in kinds", () => {
415 // Any kind in EMAIL_ELIGIBLE must map to a preference column
416 for (const k of notifyInternal.EMAIL_ELIGIBLE) {
417 expect(notifyInternal.prefFor(k)).not.toBeNull();
418 }
419 // gate_passed is not eligible (too spammy; only gate_failed is)
420 expect(notifyInternal.EMAIL_ELIGIBLE.has("gate_passed" as any)).toBe(false);
421 expect(notifyInternal.EMAIL_ELIGIBLE.has("deploy_failed" as any)).toBe(
422 false
423 );
424 });
425
426 it("notify email subject is tagged and truncated", () => {
427 const subj = notifyInternal.subjectFor("gate_failed", "x".repeat(300));
428 expect(subj.startsWith("[gate failed]")).toBe(true);
429 expect(subj.length).toBeLessThanOrEqual(180);
430 });
431});
432
433describe("settings email preferences", () => {
434 it("GET /settings redirects unauthenticated users to /login", async () => {
435 const res = await app.request("/settings");
436 expect([301, 302, 303, 307]).toContain(res.status);
437 const loc = res.headers.get("location") || "";
438 expect(loc.startsWith("/login")).toBe(true);
439 });
440
441 it("POST /settings/notifications redirects unauthenticated users to /login", async () => {
442 const res = await app.request("/settings/notifications", {
443 method: "POST",
444 headers: { "content-type": "application/x-www-form-urlencoded" },
445 body: "notify_email_on_mention=1",
446 });
447 expect([301, 302, 303, 307]).toContain(res.status);
448 const loc = res.headers.get("location") || "";
449 expect(loc.startsWith("/login")).toBe(true);
450 });
451});
6563f0aClaude452
453describe("orgs helpers (B1)", () => {
454 describe("isValidSlug", () => {
455 it("accepts simple slugs", () => {
456 expect(isValidSlug("acme")).toBe(true);
457 expect(isValidSlug("acme-corp")).toBe(true);
458 expect(isValidSlug("a1")).toBe(true);
459 expect(isValidSlug("a-b-c-1-2-3")).toBe(true);
460 });
461
462 it("rejects too-short or too-long", () => {
463 expect(isValidSlug("")).toBe(false);
464 expect(isValidSlug("a")).toBe(false);
465 expect(isValidSlug("a".repeat(40))).toBe(false);
466 });
467
468 it("rejects leading/trailing hyphen", () => {
469 expect(isValidSlug("-acme")).toBe(false);
470 expect(isValidSlug("acme-")).toBe(false);
471 });
472
473 it("rejects consecutive hyphens", () => {
474 expect(isValidSlug("foo--bar")).toBe(false);
475 });
476
477 it("rejects uppercase + invalid chars", () => {
478 expect(isValidSlug("Acme")).toBe(false);
479 expect(isValidSlug("acme_corp")).toBe(false);
480 expect(isValidSlug("acme.corp")).toBe(false);
481 expect(isValidSlug("acme corp")).toBe(false);
482 });
483
484 it("rejects reserved words", () => {
485 expect(isValidSlug("api")).toBe(false);
486 expect(isValidSlug("admin")).toBe(false);
487 expect(isValidSlug("settings")).toBe(false);
488 expect(isValidSlug("orgs")).toBe(false);
489 expect(isValidSlug("new")).toBe(false);
490 });
491 });
492
493 describe("normalizeSlug", () => {
494 it("lowercases and trims", () => {
495 expect(normalizeSlug(" ACME ")).toBe("acme");
496 expect(normalizeSlug("Acme-Corp")).toBe("acme-corp");
497 });
498 });
499
500 describe("orgRoleAtLeast", () => {
501 it("owner beats admin beats member", () => {
502 expect(orgRoleAtLeast("owner", "member")).toBe(true);
503 expect(orgRoleAtLeast("owner", "admin")).toBe(true);
504 expect(orgRoleAtLeast("owner", "owner")).toBe(true);
505 expect(orgRoleAtLeast("admin", "member")).toBe(true);
506 expect(orgRoleAtLeast("admin", "admin")).toBe(true);
507 expect(orgRoleAtLeast("admin", "owner")).toBe(false);
508 expect(orgRoleAtLeast("member", "admin")).toBe(false);
509 expect(orgRoleAtLeast("member", "owner")).toBe(false);
510 });
511
512 it("treats unknown role as rank 0", () => {
513 expect(orgRoleAtLeast("", "member")).toBe(false);
514 expect(orgRoleAtLeast("banana", "member")).toBe(false);
515 });
516 });
517
518 describe("role type guards", () => {
519 it("isValidOrgRole", () => {
520 expect(isValidOrgRole("owner")).toBe(true);
521 expect(isValidOrgRole("admin")).toBe(true);
522 expect(isValidOrgRole("member")).toBe(true);
523 expect(isValidOrgRole("maintainer")).toBe(false);
524 expect(isValidOrgRole("banana")).toBe(false);
525 });
526
527 it("isValidTeamRole", () => {
528 expect(isValidTeamRole("maintainer")).toBe(true);
529 expect(isValidTeamRole("member")).toBe(true);
530 expect(isValidTeamRole("owner")).toBe(false);
531 expect(isValidTeamRole("banana")).toBe(false);
532 });
533 });
534
535 describe("internal", () => {
536 it("rank table orders correctly", () => {
537 const r = orgsInternal.ORG_ROLE_RANK;
538 expect(r.owner).toBeGreaterThan(r.admin);
539 expect(r.admin).toBeGreaterThan(r.member);
540 });
541
542 it("reserved set contains the app's top-level paths", () => {
543 expect(orgsInternal.RESERVED_SLUGS.has("api")).toBe(true);
544 expect(orgsInternal.RESERVED_SLUGS.has("settings")).toBe(true);
545 expect(orgsInternal.RESERVED_SLUGS.has("login")).toBe(true);
546 });
547 });
548});
549
550describe("orgs routes (B1)", () => {
551 it("GET /orgs redirects unauthenticated users to /login", async () => {
552 const res = await app.request("/orgs");
553 expect([301, 302, 303, 307]).toContain(res.status);
554 const loc = res.headers.get("location") || "";
555 expect(loc.startsWith("/login")).toBe(true);
556 });
557
558 it("GET /orgs/new redirects unauthenticated users to /login", async () => {
559 const res = await app.request("/orgs/new");
560 expect([301, 302, 303, 307]).toContain(res.status);
561 const loc = res.headers.get("location") || "";
562 expect(loc.startsWith("/login")).toBe(true);
563 });
564
565 it("POST /orgs/new redirects unauthenticated users to /login", async () => {
566 const res = await app.request("/orgs/new", {
567 method: "POST",
568 headers: { "content-type": "application/x-www-form-urlencoded" },
569 body: "slug=acme&name=Acme",
570 });
571 expect([301, 302, 303, 307]).toContain(res.status);
572 const loc = res.headers.get("location") || "";
573 expect(loc.startsWith("/login")).toBe(true);
574 });
575
576 it("GET /orgs/:slug redirects unauthenticated users to /login", async () => {
577 const res = await app.request("/orgs/some-org");
578 expect([301, 302, 303, 307]).toContain(res.status);
579 const loc = res.headers.get("location") || "";
580 expect(loc.startsWith("/login")).toBe(true);
581 });
582
583 it("POST /orgs/:slug/people/add redirects unauthenticated users to /login", async () => {
584 const res = await app.request("/orgs/some-org/people/add", {
585 method: "POST",
586 headers: { "content-type": "application/x-www-form-urlencoded" },
587 body: "username=alice&role=member",
588 });
589 expect([301, 302, 303, 307]).toContain(res.status);
590 const loc = res.headers.get("location") || "";
591 expect(loc.startsWith("/login")).toBe(true);
592 });
593});
7437605Claude594
595describe("org-owned repos (B2)", () => {
596 it("GET /orgs/:slug/repos redirects unauthenticated users to /login", async () => {
597 const res = await app.request("/orgs/some-org/repos");
598 expect([301, 302, 303, 307]).toContain(res.status);
599 const loc = res.headers.get("location") || "";
600 expect(loc.startsWith("/login")).toBe(true);
601 });
602
603 it("GET /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
604 const res = await app.request("/orgs/some-org/repos/new");
605 expect([301, 302, 303, 307]).toContain(res.status);
606 const loc = res.headers.get("location") || "";
607 expect(loc.startsWith("/login")).toBe(true);
608 });
609
610 it("POST /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
611 const res = await app.request("/orgs/some-org/repos/new", {
612 method: "POST",
613 headers: { "content-type": "application/x-www-form-urlencoded" },
614 body: "name=web",
615 });
616 expect([301, 302, 303, 307]).toContain(res.status);
617 const loc = res.headers.get("location") || "";
618 expect(loc.startsWith("/login")).toBe(true);
619 });
620
621 it("POST /api/repos with orgSlug still validates required fields", async () => {
622 const res = await app.request("/api/repos", {
623 method: "POST",
624 headers: { "Content-Type": "application/json" },
625 body: JSON.stringify({ orgSlug: "acme" }),
626 });
627 // Missing name + owner → 400 before any DB access.
628 expect(res.status).toBe(400);
629 });
630
631 it("POST /api/repos rejects invalid repo names before DB access", async () => {
632 const res = await app.request("/api/repos", {
633 method: "POST",
634 headers: { "Content-Type": "application/json" },
635 body: JSON.stringify({
636 name: "bad name with spaces",
637 owner: "alice",
638 }),
639 });
640 expect(res.status).toBe(400);
641 });
642});
7298a17Claude643
644describe("TOTP / 2FA (B4)", () => {
645 it("base32 round-trips bytes", () => {
646 const bytes = new Uint8Array([0x74, 0x65, 0x73, 0x74]); // "test"
647 const enc = base32Encode(bytes);
648 const dec = base32Decode(enc);
649 expect(Array.from(dec)).toEqual(Array.from(bytes));
650 });
651
652 it("generateTotpSecret returns 32-char Base32", () => {
653 const s = generateTotpSecret();
654 expect(s.length).toBe(32);
655 expect(/^[A-Z2-7]+$/.test(s)).toBe(true);
656 });
657
658 it("totpCode is 6 digits", async () => {
659 const s = generateTotpSecret();
660 const c = await totpCode(s);
661 expect(/^\d{6}$/.test(c)).toBe(true);
662 });
663
664 it("verifyTotpCode accepts a freshly-generated code", async () => {
665 const s = generateTotpSecret();
666 const c = await totpCode(s);
667 expect(await verifyTotpCode(s, c)).toBe(true);
668 });
669
670 it("verifyTotpCode tolerates a ±30s drift", async () => {
671 const s = generateTotpSecret();
672 const now = Math.floor(Date.now() / 1000);
673 const past = await totpCode(s, now - 30);
674 const future = await totpCode(s, now + 30);
675 expect(await verifyTotpCode(s, past, now)).toBe(true);
676 expect(await verifyTotpCode(s, future, now)).toBe(true);
677 });
678
679 it("verifyTotpCode rejects a wrong code", async () => {
680 const s = generateTotpSecret();
681 expect(await verifyTotpCode(s, "000000")).toBe(false);
682 });
683
684 it("verifyTotpCode rejects non-6-digit input", async () => {
685 const s = generateTotpSecret();
686 expect(await verifyTotpCode(s, "abc")).toBe(false);
687 expect(await verifyTotpCode(s, "12345")).toBe(false);
688 expect(await verifyTotpCode(s, "1234567")).toBe(false);
689 });
690
691 it("otpauthUrl has the expected shape", () => {
692 const u = otpauthUrl({
693 secret: "JBSWY3DPEHPK3PXP",
694 accountName: "alice@example.com",
695 issuer: "gluecron",
696 });
697 expect(u.startsWith("otpauth://totp/")).toBe(true);
698 expect(u).toContain("secret=JBSWY3DPEHPK3PXP");
699 expect(u).toContain("issuer=gluecron");
700 expect(u).toContain("period=30");
701 expect(u).toContain("digits=6");
702 });
703
704 it("generateRecoveryCodes returns the expected count + format", () => {
705 const codes = generateRecoveryCodes(5);
706 expect(codes.length).toBe(5);
707 for (const c of codes) {
708 expect(/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(c)).toBe(true);
709 }
710 // Uniqueness: ~70 bits of entropy each, collisions should be astronomical.
711 expect(new Set(codes).size).toBe(5);
712 });
713
714 it("hashRecoveryCode is deterministic + normalised", async () => {
715 const a = await hashRecoveryCode("ABCD-1234-efgh");
716 const b = await hashRecoveryCode("abcd-1234-efgh");
717 const c = await hashRecoveryCode(" abcd-1234-efgh ");
718 expect(a).toBe(b);
719 expect(a).toBe(c);
720 expect(a.length).toBe(64); // SHA-256 hex
721 });
722});
723
724describe("2FA routes (B4)", () => {
725 it("GET /settings/2fa redirects unauthenticated users to /login", async () => {
726 const res = await app.request("/settings/2fa");
727 expect([301, 302, 303, 307]).toContain(res.status);
728 const loc = res.headers.get("location") || "";
729 expect(loc.startsWith("/login")).toBe(true);
730 });
731
732 it("POST /settings/2fa/enroll redirects unauthenticated users to /login", async () => {
733 const res = await app.request("/settings/2fa/enroll", {
734 method: "POST",
735 headers: { "content-type": "application/x-www-form-urlencoded" },
736 body: "",
737 });
738 expect([301, 302, 303, 307]).toContain(res.status);
739 const loc = res.headers.get("location") || "";
740 expect(loc.startsWith("/login")).toBe(true);
741 });
742
743 it("GET /login/2fa redirects to /login when no session cookie", async () => {
744 const res = await app.request("/login/2fa");
745 expect([301, 302, 303, 307]).toContain(res.status);
746 const loc = res.headers.get("location") || "";
747 expect(loc.startsWith("/login")).toBe(true);
748 });
749});
2df1f8cClaude750
751describe("passkeys routes (B5)", () => {
752 it("GET /settings/passkeys redirects unauthenticated users to /login", async () => {
753 const res = await app.request("/settings/passkeys");
754 expect([301, 302, 303, 307]).toContain(res.status);
755 const loc = res.headers.get("location") || "";
756 expect(loc.startsWith("/login")).toBe(true);
757 });
758
759 it("POST /api/passkeys/register/options redirects unauthenticated users to /login", async () => {
760 const res = await app.request("/api/passkeys/register/options", {
761 method: "POST",
762 headers: { "content-type": "application/json" },
763 body: "{}",
764 });
765 expect([301, 302, 303, 307]).toContain(res.status);
766 const loc = res.headers.get("location") || "";
767 expect(loc.startsWith("/login")).toBe(true);
768 });
769
770 it("POST /api/passkeys/auth/verify returns 400 when body is invalid", async () => {
771 const res = await app.request("/api/passkeys/auth/verify", {
772 method: "POST",
773 headers: { "content-type": "application/json" },
774 body: "not json",
775 });
776 // Either 400 (bad JSON) or 503 (DB down) — never a 500.
777 expect([400, 503]).toContain(res.status);
778 });
779
780 it("POST /api/passkeys/auth/verify rejects missing fields", async () => {
781 const res = await app.request("/api/passkeys/auth/verify", {
782 method: "POST",
783 headers: { "content-type": "application/json" },
784 body: "{}",
785 });
786 expect([400, 503]).toContain(res.status);
787 });
788
789 it("POST /settings/passkeys/:id/delete redirects unauthenticated users to /login", async () => {
790 const res = await app.request("/settings/passkeys/abc/delete", {
791 method: "POST",
792 headers: { "content-type": "application/x-www-form-urlencoded" },
793 body: "",
794 });
795 expect([301, 302, 303, 307]).toContain(res.status);
796 const loc = res.headers.get("location") || "";
797 expect(loc.startsWith("/login")).toBe(true);
798 });
799});