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