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

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