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