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

api-v2-actions.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.

api-v2-actions.test.tsBlame497 lines · 2 contributors
4366c70Claude1/**
2 * API v2 — Actions / Workflows REST surface (addendum group 3).
3 *
4 * Covers the five GitHub-compatible endpoints added in `src/routes/api-v2.ts`:
5 *
6 * POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches
7 * GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs
8 * GET /api/v2/repos/:owner/:repo/actions/runs/:run_id
9 * GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs
10 * POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel
11 *
12 * Auth/validation checks run without a database; the deeper end-to-end
13 * flows (dispatch → list → cancel → logs zip) are DB-gated via the standard
14 * HAS_DB pattern so the suite stays green on machines without Postgres.
15 */
16
17import { describe, it, expect, beforeAll, afterAll } from "bun:test";
18import { join } from "path";
19import { rm, mkdir } from "fs/promises";
20import { randomBytes } from "crypto";
21import app from "../app";
22import { clearRateLimitStore } from "../middleware/rate-limit";
23import { initBareRepo, getRepoPath } from "../git/repository";
24
25const HAS_DB = Boolean(process.env.DATABASE_URL);
26const TEST_REPOS = join(
27 import.meta.dir,
28 "../../.test-repos-api-v2-actions-" + Date.now()
29);
30
31beforeAll(async () => {
32 process.env.GIT_REPOS_PATH = TEST_REPOS;
33 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
34 clearRateLimitStore();
35 await rm(TEST_REPOS, { recursive: true, force: true });
36 await mkdir(TEST_REPOS, { recursive: true });
37});
38
39afterAll(async () => {
40 await rm(TEST_REPOS, { recursive: true, force: true });
41});
42
43// ---------------------------------------------------------------------------
44// Helpers
45// ---------------------------------------------------------------------------
46
47function apiUrl(path: string): string {
48 return `/api/v2${path}`;
49}
50
51function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
52 return { "Content-Type": "application/json", ...extra };
53}
54
55async function run(cmd: string[], cwd: string) {
56 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
57 await new Response(proc.stdout).text();
779c681ccantynz-alt58 const __code = await proc.exited;
59 // Fail loudly. These fixtures sit under the project dir, so a silently
60 // failed clone leaves cwd inside the REAL repo and the following git
61 // config/add/commit then mutate it. See week3.test.ts.
62 if (__code !== 0) throw new Error(`git fixture command failed (exit ${__code})`);
4366c70Claude63}
64
65async function seedBareRepoWithCommit(owner: string, name: string) {
66 await initBareRepo(owner, name);
67 const bare = getRepoPath(owner, name);
68 const work = join(TEST_REPOS, "_work_" + randomBytes(4).toString("hex"));
69 await mkdir(work, { recursive: true });
70 await run(["git", "clone", bare, work], TEST_REPOS);
71 await run(["git", "config", "user.email", "test@gluecron.com"], work);
72 await run(["git", "config", "user.name", "Test User"], work);
73 await run(["git", "checkout", "-B", "main"], work);
74 await Bun.write(join(work, "README.md"), "# hi\n");
75 await run(["git", "add", "-A"], work);
76 await run(["git", "commit", "-m", "seed"], work);
77 await run(["git", "push", "-u", "origin", "main"], work);
78 await rm(work, { recursive: true, force: true });
79}
80
81// SHA-256 hex for the API-token loader. Matches src/middleware/api-auth.ts.
82async function sha256Hex(s: string): Promise<string> {
83 const hasher = new Bun.CryptoHasher("sha256");
84 hasher.update(s);
85 return hasher.digest("hex");
86}
87
88// ---------------------------------------------------------------------------
89// 1. Auth/validation — no DB required
90// ---------------------------------------------------------------------------
91
92describe("API v2 actions — auth + validation (no DB)", () => {
93 it("POST /actions/workflows/:f/dispatches without auth returns 401", async () => {
94 const res = await app.request(
95 apiUrl("/repos/nobody/nothing/actions/workflows/ci.yml/dispatches"),
96 {
97 method: "POST",
98 headers: jsonHeaders(),
99 body: JSON.stringify({ ref: "main" }),
100 }
101 );
102 expect(res.status).toBe(401);
103 });
104
105 it("POST /actions/runs/:id/cancel without auth returns 401", async () => {
106 const res = await app.request(
107 apiUrl(
108 "/repos/nobody/nothing/actions/runs/00000000-0000-0000-0000-000000000000/cancel"
109 ),
110 { method: "POST", headers: jsonHeaders() }
111 );
112 expect(res.status).toBe(401);
113 });
114
115 it("GET /actions/workflows/:f/runs on missing repo returns 404 or 500", async () => {
116 const res = await app.request(
117 apiUrl("/repos/nobody/nothing/actions/workflows/ci.yml/runs")
118 );
119 expect([404, 500]).toContain(res.status);
120 });
121
122 it("GET /actions/runs/:id on missing repo returns 404 or 500", async () => {
123 const res = await app.request(
124 apiUrl(
125 "/repos/nobody/nothing/actions/runs/00000000-0000-0000-0000-000000000000"
126 )
127 );
128 expect([404, 500]).toContain(res.status);
129 });
130
131 it("GET /actions/runs/:id/logs on missing repo returns 404 or 500", async () => {
132 const res = await app.request(
133 apiUrl(
134 "/repos/nobody/nothing/actions/runs/00000000-0000-0000-0000-000000000000/logs"
135 )
136 );
137 expect([404, 500]).toContain(res.status);
138 });
139});
140
141// ---------------------------------------------------------------------------
142// 2. End-to-end with a real DB + seeded bare repo
143// ---------------------------------------------------------------------------
144
145describe.skipIf(!HAS_DB)("API v2 actions — DB-backed flows", () => {
146 it("dispatch → list → get → cancel → logs.zip", async () => {
147 const { db } = await import("../db");
148 const {
149 users,
150 repositories,
151 apiTokens,
152 workflows,
153 workflowRuns,
154 workflowJobs,
155 } = await import("../db/schema");
156 const { eq } = await import("drizzle-orm");
157
158 const stamp = randomBytes(4).toString("hex");
159 const username = `actuser-${stamp}`;
160 const reponame = `actrepo-${stamp}`;
161
162 // ── user + bare repo ──
163 const [u] = await db
164 .insert(users)
165 .values({
166 username,
167 email: `${username}@test.local`,
168 passwordHash: "x",
169 })
170 .returning();
171 expect(u).toBeDefined();
172 if (!u) return;
173
174 await seedBareRepoWithCommit(username, reponame);
175
176 const [r] = await db
177 .insert(repositories)
178 .values({
179 name: reponame,
180 ownerId: u.id,
181 diskPath: getRepoPath(username, reponame),
182 defaultBranch: "main",
183 })
184 .returning();
185 expect(r).toBeDefined();
186 if (!r) return;
187
188 // ── workflow row (parsed.on includes workflow_dispatch as a string) ──
189 const parsed = {
190 name: "CI",
191 on: ["push", "workflow_dispatch"],
192 jobs: [{ name: "build", runsOn: "default", steps: [{ name: "x", run: "echo hi" }] }],
193 };
194 const [wf] = await db
195 .insert(workflows)
196 .values({
197 repositoryId: r.id,
198 name: "CI",
199 path: ".gluecron/workflows/ci.yml",
200 yaml: "name: CI\non: [push, workflow_dispatch]\njobs:\n build:\n steps:\n - run: echo hi\n",
201 parsed: JSON.stringify(parsed),
202 onEvents: JSON.stringify(parsed.on),
203 })
204 .returning();
205 expect(wf).toBeDefined();
206 if (!wf) return;
207
208 // ── PAT with `repo` scope ──
209 const tokenPlain = "glc_" + randomBytes(32).toString("hex");
210 const tokenHash = await sha256Hex(tokenPlain);
211 await db.insert(apiTokens).values({
212 userId: u.id,
213 name: `test-${stamp}`,
214 tokenHash,
215 tokenPrefix: tokenPlain.slice(0, 12),
216 scopes: "repo",
217 });
218 const bearer = { Authorization: `Bearer ${tokenPlain}` };
219
220 // ── 1. dispatch the workflow ──
221 const disp = await app.request(
222 apiUrl(
223 `/repos/${username}/${reponame}/actions/workflows/ci.yml/dispatches`
224 ),
225 {
226 method: "POST",
227 headers: jsonHeaders(bearer),
228 body: JSON.stringify({ ref: "main" }),
229 }
230 );
231 expect(disp.status).toBe(204);
232
233 // The run should now exist; pull it back via the list endpoint.
234 const list = await app.request(
235 apiUrl(
236 `/repos/${username}/${reponame}/actions/workflows/ci.yml/runs`
237 ),
238 { headers: bearer }
239 );
240 expect(list.status).toBe(200);
241 const listBody = (await list.json()) as any;
242 expect(listBody.total_count).toBeGreaterThanOrEqual(1);
243 expect(Array.isArray(listBody.workflow_runs)).toBe(true);
244 const runEntry = listBody.workflow_runs[0];
245 expect(runEntry.id).toBeDefined();
246 expect(runEntry.head_branch).toBe("main");
247 expect(runEntry.event).toBe("workflow_dispatch");
248 expect(runEntry.html_url).toContain(
249 `/${username}/${reponame}/actions/runs/${runEntry.id}`
250 );
251
252 // ── 2. fetch the run by id ──
253 const single = await app.request(
254 apiUrl(`/repos/${username}/${reponame}/actions/runs/${runEntry.id}`),
255 { headers: bearer }
256 );
257 expect(single.status).toBe(200);
258 const singleBody = (await single.json()) as any;
259 expect(singleBody.id).toBe(runEntry.id);
260 expect(singleBody.name).toBe("CI");
261
262 // Cross-repo isolation: a different (seeded) repo should not see this run.
263 const otherRepoName = `other-${stamp}`;
264 await seedBareRepoWithCommit(username, otherRepoName);
265 const [r2] = await db
266 .insert(repositories)
267 .values({
268 name: otherRepoName,
269 ownerId: u.id,
270 diskPath: getRepoPath(username, otherRepoName),
271 defaultBranch: "main",
272 })
273 .returning();
274 expect(r2).toBeDefined();
275 const cross = await app.request(
276 apiUrl(`/repos/${username}/${otherRepoName}/actions/runs/${runEntry.id}`),
277 { headers: bearer }
278 );
279 expect(cross.status).toBe(404);
280
281 // ── 3. attach a job with logs, then download the zip ──
282 await db.insert(workflowJobs).values({
283 runId: runEntry.id,
284 name: "build",
285 jobOrder: 0,
286 runsOn: "default",
287 status: "success",
288 conclusion: "success",
289 steps: JSON.stringify([{ name: "x", exitCode: 0 }]),
290 logs: "hello from build job\n",
291 });
292 const logs = await app.request(
293 apiUrl(
294 `/repos/${username}/${reponame}/actions/runs/${runEntry.id}/logs`
295 ),
296 { headers: bearer }
297 );
298 expect(logs.status).toBe(200);
299 expect(logs.headers.get("content-type")).toContain("application/zip");
300 const bytes = new Uint8Array(await logs.arrayBuffer());
301 // PKZIP local file header magic — `PK\x03\x04`.
302 expect(bytes[0]).toBe(0x50);
303 expect(bytes[1]).toBe(0x4b);
304 expect(bytes[2]).toBe(0x03);
305 expect(bytes[3]).toBe(0x04);
306
307 // ── 4. cancel a queued run ──
308 const cancel = await app.request(
309 apiUrl(
310 `/repos/${username}/${reponame}/actions/runs/${runEntry.id}/cancel`
311 ),
312 { method: "POST", headers: jsonHeaders(bearer) }
313 );
314 expect(cancel.status).toBe(202);
315
316 const [after] = await db
317 .select()
318 .from(workflowRuns)
319 .where(eq(workflowRuns.id, runEntry.id))
320 .limit(1);
321 expect(after?.status).toBe("cancelled");
322 expect(after?.conclusion).toBe("cancelled");
323 expect(after?.finishedAt).not.toBeNull();
324
325 // Re-cancelling a terminal run is a 409 Conflict.
326 const reCancel = await app.request(
327 apiUrl(
328 `/repos/${username}/${reponame}/actions/runs/${runEntry.id}/cancel`
329 ),
330 { method: "POST", headers: jsonHeaders(bearer) }
331 );
332 expect(reCancel.status).toBe(409);
333 });
334
335 it("dispatch on a workflow without workflow_dispatch returns 422", async () => {
336 const { db } = await import("../db");
337 const { users, repositories, apiTokens, workflows } = await import(
338 "../db/schema"
339 );
340
341 const stamp = randomBytes(4).toString("hex");
342 const username = `actuser-422-${stamp}`;
343 const reponame = `actrepo-422-${stamp}`;
344
345 const [u] = await db
346 .insert(users)
347 .values({
348 username,
349 email: `${username}@test.local`,
350 passwordHash: "x",
351 })
352 .returning();
353 if (!u) return;
354 await seedBareRepoWithCommit(username, reponame);
355 const [r] = await db
356 .insert(repositories)
357 .values({
358 name: reponame,
359 ownerId: u.id,
360 diskPath: getRepoPath(username, reponame),
361 defaultBranch: "main",
362 })
363 .returning();
364 if (!r) return;
365
366 // Only `push` — no workflow_dispatch.
367 const parsed = {
368 name: "push-only",
369 on: ["push"],
370 jobs: [{ name: "build", runsOn: "default", steps: [{ name: "x", run: "echo hi" }] }],
371 };
372 await db.insert(workflows).values({
373 repositoryId: r.id,
374 name: "push-only",
375 path: ".gluecron/workflows/push.yml",
376 yaml: "name: push-only\non: push\njobs:\n build:\n steps:\n - run: echo hi\n",
377 parsed: JSON.stringify(parsed),
378 onEvents: JSON.stringify(parsed.on),
379 });
380
381 const tokenPlain = "glc_" + randomBytes(32).toString("hex");
382 const tokenHash = await sha256Hex(tokenPlain);
383 await db.insert(apiTokens).values({
384 userId: u.id,
385 name: `test-${stamp}`,
386 tokenHash,
387 tokenPrefix: tokenPlain.slice(0, 12),
388 scopes: "repo",
389 });
390
391 const res = await app.request(
392 apiUrl(
393 `/repos/${username}/${reponame}/actions/workflows/push.yml/dispatches`
394 ),
395 {
396 method: "POST",
397 headers: jsonHeaders({ Authorization: `Bearer ${tokenPlain}` }),
398 body: JSON.stringify({ ref: "main" }),
399 }
400 );
401 expect(res.status).toBe(422);
402 });
403
404 it("dispatch with a missing required input returns 422 with details", async () => {
405 const { db } = await import("../db");
406 const { users, repositories, apiTokens, workflows } = await import(
407 "../db/schema"
408 );
409
410 const stamp = randomBytes(4).toString("hex");
411 const username = `actuser-inp-${stamp}`;
412 const reponame = `actrepo-inp-${stamp}`;
413
414 const [u] = await db
415 .insert(users)
416 .values({
417 username,
418 email: `${username}@test.local`,
419 passwordHash: "x",
420 })
421 .returning();
422 if (!u) return;
423 await seedBareRepoWithCommit(username, reponame);
424 const [r] = await db
425 .insert(repositories)
426 .values({
427 name: reponame,
428 ownerId: u.id,
429 diskPath: getRepoPath(username, reponame),
430 defaultBranch: "main",
431 })
432 .returning();
433 if (!r) return;
434
435 // Mapping-shaped `on` with workflow_dispatch.inputs — the only place an
436 // inputs schema can live.
437 const parsed = {
438 name: "needs-input",
439 on: {
440 workflow_dispatch: {
441 inputs: {
442 target: { type: "string", required: true, description: "required" },
443 level: { type: "string", required: false, default: "info" },
444 },
445 },
446 },
447 jobs: [{ name: "build", runsOn: "default", steps: [{ name: "x", run: "echo" }] }],
448 };
449 await db.insert(workflows).values({
450 repositoryId: r.id,
451 name: "needs-input",
452 path: ".gluecron/workflows/dispatch.yml",
453 yaml: "name: needs-input\non:\n workflow_dispatch:\n inputs:\n target:\n required: true\njobs:\n build:\n steps:\n - run: echo\n",
454 parsed: JSON.stringify(parsed),
455 onEvents: JSON.stringify(["workflow_dispatch"]),
456 });
457
458 const tokenPlain = "glc_" + randomBytes(32).toString("hex");
459 const tokenHash = await sha256Hex(tokenPlain);
460 await db.insert(apiTokens).values({
461 userId: u.id,
462 name: `test-${stamp}`,
463 tokenHash,
464 tokenPrefix: tokenPlain.slice(0, 12),
465 scopes: "repo",
466 });
467
468 const bad = await app.request(
469 apiUrl(
470 `/repos/${username}/${reponame}/actions/workflows/dispatch.yml/dispatches`
471 ),
472 {
473 method: "POST",
474 headers: jsonHeaders({ Authorization: `Bearer ${tokenPlain}` }),
475 body: JSON.stringify({ ref: "main", inputs: {} }),
476 }
477 );
478 expect(bad.status).toBe(422);
479 const badBody = (await bad.json()) as any;
480 expect(Array.isArray(badBody.details)).toBe(true);
481 expect(badBody.details.join("\n")).toMatch(/target/);
482
483 // Supplying the required input clears the gate (the run enqueue itself
484 // is what we're verifying, not the worker — so 204 is the contract).
485 const good = await app.request(
486 apiUrl(
487 `/repos/${username}/${reponame}/actions/workflows/dispatch.yml/dispatches`
488 ),
489 {
490 method: "POST",
491 headers: jsonHeaders({ Authorization: `Bearer ${tokenPlain}` }),
492 body: JSON.stringify({ ref: "main", inputs: { target: "prod" } }),
493 }
494 );
495 expect(good.status).toBe(204);
496 });
497});