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-gatetest.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-gatetest.test.tsBlame378 lines · 2 contributors
052c2e6Claude1/**
2 * API v2 — GateTest integration endpoints.
3 *
4 * These tests cover the additive endpoints introduced for GateTest's
5 * GluecronBridge:
6 * - recursive tree listing (cap 50k + truncation)
7 * - base64 content reads (PNG round-trip)
8 * - PR-comment POST auth check
9 * - POST /git/refs conflict + unknown-sha
10 * - PUT /contents/:path sha-mismatch
11 * - commit-status v2 alias
12 *
13 * Git-layer tests use real bare repos on disk; HTTP-layer tests use Hono's
14 * in-memory request dispatcher without a DB connection (matching the style
15 * of src/__tests__/api-v2.test.ts).
16 */
17
18import { describe, it, expect, beforeAll, afterAll } from "bun:test";
19import { join } from "path";
20import { rm, mkdir } from "fs/promises";
21import app from "../app";
22import { clearRateLimitStore } from "../middleware/rate-limit";
23import {
24 initBareRepo,
25 getRepoPath,
26 getDefaultBranchFresh,
27 getTreeRecursive,
28 catBlobBytes,
29 refExists,
30 objectExists,
31 updateRef,
32 writeBlob,
33 createOrUpdateFileOnBranch,
34 getBlobShaAtPath,
35 resolveRef,
36} from "../git/repository";
37
38const TEST_REPOS = join(import.meta.dir, "../../.test-repos-gatetest-" + Date.now());
39
40beforeAll(async () => {
41 process.env.GIT_REPOS_PATH = TEST_REPOS;
42 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
43 clearRateLimitStore();
44 await rm(TEST_REPOS, { recursive: true, force: true });
45 await mkdir(TEST_REPOS, { recursive: true });
46});
47
48afterAll(async () => {
49 await rm(TEST_REPOS, { recursive: true, force: true });
50});
51
52// ---------------------------------------------------------------------------
53// Helpers to build a real bare repo with a seeded commit
54// ---------------------------------------------------------------------------
55
56async function run(cmd: string[], cwd: string) {
57 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
58 const out = (await new Response(proc.stdout).text()).trim();
779c681ccantynz-alt59 const __code = await proc.exited;
60 // Fail loudly. These fixtures sit under the project dir, so a silently
61 // failed clone leaves cwd inside the REAL repo and the following git
62 // config/add/commit then mutate it. See week3.test.ts.
63 if (__code !== 0) throw new Error(`git fixture command failed (exit ${__code})`);
052c2e6Claude64 return out;
65}
66
67async function seedRepo(
68 owner: string,
69 name: string,
70 files: Array<{ path: string; bytes: Uint8Array | string }>,
71 opts: { branch?: string } = {}
72) {
73 const branch = opts.branch ?? "main";
74 await initBareRepo(owner, name);
75 const bare = getRepoPath(owner, name);
76
77 const work = join(TEST_REPOS, "_work_" + Math.random().toString(16).slice(2));
78 await mkdir(work, { recursive: true });
79 await run(["git", "clone", bare, work], TEST_REPOS);
80 await run(["git", "config", "user.email", "test@gluecron.com"], work);
81 await run(["git", "config", "user.name", "Test User"], work);
82 await run(["git", "checkout", "-B", branch], work);
83
84 for (const f of files) {
85 const full = join(work, f.path);
86 const dir = full.substring(0, full.lastIndexOf("/"));
87 if (dir && dir !== work) await mkdir(dir, { recursive: true });
88 const data = typeof f.bytes === "string" ? new TextEncoder().encode(f.bytes) : f.bytes;
89 await Bun.write(full, data);
90 }
91
92 await run(["git", "add", "-A"], work);
93 await run(["git", "commit", "-m", "seed"], work);
94 await run(["git", "push", "-u", "origin", branch], work);
95 await rm(work, { recursive: true, force: true });
96}
97
98// ---------------------------------------------------------------------------
99// 1. getDefaultBranchFresh
100// ---------------------------------------------------------------------------
101
102describe("git — getDefaultBranchFresh", () => {
103 it("returns 'main' for a freshly initialised bare repo", async () => {
104 await initBareRepo("u1", "r1");
105 const branch = await getDefaultBranchFresh("u1", "r1");
106 expect(branch).toBe("main");
107 });
108
109 it("strips refs/heads/ prefix from HEAD's symbolic ref", async () => {
110 await initBareRepo("u2", "r2");
111 const bare = getRepoPath("u2", "r2");
112 await run(["git", "symbolic-ref", "HEAD", "refs/heads/develop"], bare);
113 const branch = await getDefaultBranchFresh("u2", "r2");
114 expect(branch).toBe("develop");
115 });
116});
117
118// ---------------------------------------------------------------------------
119// 2. Recursive tree
120// ---------------------------------------------------------------------------
121
122describe("git — getTreeRecursive", () => {
123 it("walks all blobs and trees under the ref", async () => {
124 await seedRepo("u3", "r3", [
125 { path: "README.md", bytes: "# hi" },
126 { path: "src/a.ts", bytes: "export const a = 1" },
127 { path: "src/sub/b.ts", bytes: "export const b = 2" },
128 ]);
129 const result = await getTreeRecursive("u3", "r3", "main");
130 expect(result).not.toBeNull();
131 expect(result!.truncated).toBe(false);
132 const paths = result!.tree.map((e) => e.path);
133 expect(paths).toContain("README.md");
134 expect(paths).toContain("src");
135 expect(paths).toContain("src/a.ts");
136 expect(paths).toContain("src/sub");
137 expect(paths).toContain("src/sub/b.ts");
138 // blob entries carry a size
139 const blob = result!.tree.find((e) => e.path === "src/a.ts");
140 expect(blob?.type).toBe("blob");
141 expect(typeof blob?.size).toBe("number");
142 });
143
144 it("truncates + sets truncated=true when over-cap", async () => {
145 await seedRepo("u4", "r4", [
146 { path: "a.txt", bytes: "1" },
147 { path: "b.txt", bytes: "2" },
148 { path: "c.txt", bytes: "3" },
149 { path: "d.txt", bytes: "4" },
150 ]);
151 const result = await getTreeRecursive("u4", "r4", "main", 2);
152 expect(result).not.toBeNull();
153 expect(result!.truncated).toBe(true);
154 expect(result!.tree.length).toBe(2);
155 expect(result!.totalCount).toBeGreaterThanOrEqual(4);
156 });
157
158 it("returns null for a ref that does not resolve", async () => {
159 await initBareRepo("u5", "r5");
160 const result = await getTreeRecursive("u5", "r5", "no-such-branch");
161 expect(result).toBeNull();
162 });
163});
164
165// ---------------------------------------------------------------------------
166// 3. catBlobBytes — binary round-trip (PNG magic)
167// ---------------------------------------------------------------------------
168
169describe("git — catBlobBytes / base64 round-trip", () => {
170 it("preserves PNG magic bytes through git storage", async () => {
171 // Minimal 1x1 transparent PNG.
172 const pngBytes = new Uint8Array([
173 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
174 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
175 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
176 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
177 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
178 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
179 ]);
180 await seedRepo("u6", "r6", [{ path: "pixel.png", bytes: pngBytes }]);
181
182 const result = await catBlobBytes("u6", "r6", "main", "pixel.png");
183 expect(result).not.toBeNull();
184 expect(result!.size).toBe(pngBytes.length);
185 // First 8 bytes are the PNG magic signature.
186 for (let i = 0; i < 8; i++) expect(result!.bytes[i]).toBe(pngBytes[i]);
187
188 const base64 = Buffer.from(result!.bytes).toString("base64");
189 const decoded = Buffer.from(base64, "base64");
190 expect(decoded.length).toBe(pngBytes.length);
191 for (let i = 0; i < pngBytes.length; i++) expect(decoded[i]).toBe(pngBytes[i]);
192 });
193
194 it("returns null when the path is missing", async () => {
195 await seedRepo("u7", "r7", [{ path: "x.txt", bytes: "hi" }]);
196 const result = await catBlobBytes("u7", "r7", "main", "no-such-file");
197 expect(result).toBeNull();
198 });
199});
200
201// ---------------------------------------------------------------------------
202// 4. refExists, objectExists, updateRef
203// ---------------------------------------------------------------------------
204
205describe("git — ref + object helpers", () => {
206 it("refExists returns true for existing refs, false otherwise", async () => {
207 await seedRepo("u8", "r8", [{ path: "f.txt", bytes: "hi" }]);
208 expect(await refExists("u8", "r8", "refs/heads/main")).toBe(true);
209 expect(await refExists("u8", "r8", "refs/heads/nope")).toBe(false);
210 });
211
212 it("objectExists validates reachable shas", async () => {
213 await seedRepo("u9", "r9", [{ path: "f.txt", bytes: "hi" }]);
214 const sha = await resolveRef("u9", "r9", "HEAD");
215 expect(sha).not.toBeNull();
216 expect(await objectExists("u9", "r9", sha!)).toBe(true);
217 expect(await objectExists("u9", "r9", "0".repeat(40))).toBe(false);
218 });
219
220 it("updateRef points a new branch at an existing sha", async () => {
221 await seedRepo("u10", "r10", [{ path: "f.txt", bytes: "hi" }]);
222 const sha = await resolveRef("u10", "r10", "HEAD");
223 expect(await updateRef("u10", "r10", "refs/heads/feature-x", sha!)).toBe(true);
224 expect(await refExists("u10", "r10", "refs/heads/feature-x")).toBe(true);
225 });
226});
227
228// ---------------------------------------------------------------------------
229// 5. writeBlob + createOrUpdateFileOnBranch + sha-mismatch
230// ---------------------------------------------------------------------------
231
232describe("git — createOrUpdateFileOnBranch", () => {
233 it("appends a new file and moves the branch ref", async () => {
234 await seedRepo("u11", "r11", [{ path: "README.md", bytes: "# hi" }]);
235 const parent = await resolveRef("u11", "r11", "refs/heads/main");
236
237 const result = await createOrUpdateFileOnBranch({
238 owner: "u11",
239 name: "r11",
240 branch: "main",
241 filePath: "src/new.ts",
242 bytes: new TextEncoder().encode("export const x = 1;\n"),
243 message: "add new.ts",
244 authorName: "Test User",
245 authorEmail: "test@gluecron.com",
246 expectBlobSha: null,
247 });
248
249 expect("error" in result).toBe(false);
250 if ("error" in result) return;
251 expect(result.parentSha).toBe(parent);
252 expect(result.commitSha).not.toBe(parent);
253
254 // The branch ref now points at the new commit.
255 const head = await resolveRef("u11", "r11", "refs/heads/main");
256 expect(head).toBe(result.commitSha);
257
258 // And the new blob is present on the branch.
259 const blobSha = await getBlobShaAtPath("u11", "r11", "main", "src/new.ts");
260 expect(blobSha).toBe(result.blobSha);
261 });
262
263 it("updates an existing file in place", async () => {
264 await seedRepo("u12", "r12", [{ path: "README.md", bytes: "v1" }]);
265 const oldBlob = await getBlobShaAtPath("u12", "r12", "main", "README.md");
266 expect(oldBlob).not.toBeNull();
267
268 const result = await createOrUpdateFileOnBranch({
269 owner: "u12",
270 name: "r12",
271 branch: "main",
272 filePath: "README.md",
273 bytes: new TextEncoder().encode("v2"),
274 message: "bump README",
275 authorName: "Test User",
276 authorEmail: "test@gluecron.com",
277 expectBlobSha: oldBlob,
278 });
279
280 expect("error" in result).toBe(false);
281 if ("error" in result) return;
282 const newBlob = await getBlobShaAtPath("u12", "r12", "main", "README.md");
283 expect(newBlob).toBe(result.blobSha);
284 expect(newBlob).not.toBe(oldBlob);
285 });
286
287 it("returns sha-mismatch when the optimistic sha check fails", async () => {
288 await seedRepo("u13", "r13", [{ path: "README.md", bytes: "v1" }]);
289 const result = await createOrUpdateFileOnBranch({
290 owner: "u13",
291 name: "r13",
292 branch: "main",
293 filePath: "README.md",
294 bytes: new TextEncoder().encode("v2"),
295 message: "bump",
296 authorName: "Test User",
297 authorEmail: "test@gluecron.com",
298 expectBlobSha: "0".repeat(40), // never matches
299 });
300 expect("error" in result).toBe(true);
301 if ("error" in result) expect(result.error).toBe("sha-mismatch");
302 });
303
304 it("writeBlob produces a deterministic 40-hex sha", async () => {
305 await initBareRepo("u14", "r14");
306 const sha = await writeBlob("u14", "r14", new TextEncoder().encode("hello"));
307 expect(sha).toMatch(/^[0-9a-f]{40}$/);
308 });
309});
310
311// ---------------------------------------------------------------------------
312// 6. HTTP — auth/validation on new routes (no DB required)
313// ---------------------------------------------------------------------------
314
315function apiUrl(path: string): string {
316 return `/api/v2${path}`;
317}
318
319function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
320 return { "Content-Type": "application/json", ...extra };
321}
322
323describe("API v2 — new routes auth + validation", () => {
324 it("POST /pulls/:n/comments without auth returns 401", async () => {
325 const res = await app.request(apiUrl("/repos/nobody/nothing/pulls/1/comments"), {
326 method: "POST",
327 headers: jsonHeaders(),
328 body: JSON.stringify({ body: "hello" }),
329 });
330 expect(res.status).toBe(401);
331 });
332
333 it("POST /git/refs without auth returns 401", async () => {
334 const res = await app.request(apiUrl("/repos/nobody/nothing/git/refs"), {
335 method: "POST",
336 headers: jsonHeaders(),
337 body: JSON.stringify({ ref: "refs/heads/x", sha: "a".repeat(40) }),
338 });
339 expect(res.status).toBe(401);
340 });
341
342 it("PUT /contents/:path without auth returns 401", async () => {
343 const res = await app.request(apiUrl("/repos/nobody/nothing/contents/foo.txt"), {
344 method: "PUT",
345 headers: jsonHeaders(),
346 body: JSON.stringify({
347 message: "m",
348 content: Buffer.from("hi").toString("base64"),
349 branch: "main",
350 }),
351 });
352 expect(res.status).toBe(401);
353 });
354
355 it("POST v2 /statuses/:sha without auth returns 401", async () => {
356 const res = await app.request(
357 apiUrl("/repos/nobody/nothing/statuses/" + "a".repeat(40)),
358 {
359 method: "POST",
360 headers: jsonHeaders(),
361 body: JSON.stringify({ state: "success" }),
362 }
363 );
364 expect(res.status).toBe(401);
365 });
366
367 it("GET tree?recursive=1 on missing repo returns 404 or 500", async () => {
368 const res = await app.request(apiUrl("/repos/nobody/nothing/tree/main?recursive=1"));
369 expect([404, 500]).toContain(res.status);
370 });
371
372 it("GET contents?encoding=base64 on missing repo returns 404 or 500", async () => {
373 const res = await app.request(
374 apiUrl("/repos/nobody/nothing/contents/any?encoding=base64")
375 );
376 expect([404, 500]).toContain(res.status);
377 });
378});