zuka
zuka/test/live/wire_test.ts

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/test/live/wire_test.ts
TSwire_test.ts64.9 KBDownload
1/**
2 * Live suite: spawns the compiled binary and drives it with a real `git` CLI.
3 *
4 * This is where the wire protocol and the REST contract are actually proven — the
5 * inline Rust tests cover units, but only a real clone and push prove M1. CI sets
6 * ZUKA_LIVE_TEST; without it these skip so a plain `deno test` stays fast.
7 */
8import {
9 assert,
10 assertEquals,
11 assertRejects,
12 assertStringIncludes,
13} from "jsr:@std/assert@^1";
14
15const ENABLED = Deno.env.get("ZUKA_LIVE_TEST") === "1";
16const ROOT = new URL("../../", import.meta.url).pathname;
17
18/** Crate name, read from Cargo.toml so a rename does not break the suite. */
19const NAME = (Deno.readTextFileSync(`${ROOT}Cargo.toml`)
20 .match(/^name\s*=\s*"([^"]+)"/m)?.[1]) ?? "zuka";
21/**
22 * The binary under test.
23 *
24 * Overridable because CI builds a static musl binary and deploys that exact file —
25 * so that is the one the live suite has to exercise. Testing a second, differently
26 * linked build and shipping the first proves nothing about what ships.
27 */
28const BINARY = Deno.env.get("ZUKA_TEST_BINARY") ??
29 `${ROOT}target/release/${NAME}`;
30
31interface Server {
32 port: number;
33 sshPort: number;
34 dataDir: string;
35 workDir: string;
36 token: string;
37 /// Private key registered with the `alice` account.
38 keyPath: string;
39 stop: () => Promise<void>;
40}
41
42async function freePort(): Promise<number> {
43 const listener = Deno.listen({ port: 0 });
44 const { port } = listener.addr as Deno.NetAddr;
45 listener.close();
46 return port;
47}
48
49/** Mint a token by invoking the binary's own command, as an operator would. */
50async function mintToken(
51 dataDir: string,
52 account: string,
53 extra: string[] = ["--scopes", "admin"],
54): Promise<string> {
55 const command = new Deno.Command(BINARY, {
56 args: ["token", account, ...extra],
57 env: { ZUKA_DATA_DIR: dataDir },
58 stdout: "piped",
59 stderr: "null",
60 });
61 const { success, stdout } = await command.output();
62 assert(success, `minting a token for ${account} failed`);
63 return new TextDecoder().decode(stdout).trim();
64}
65
66/** Generate a keypair and register the public half with an account. */
67async function registerKey(
68 dataDir: string,
69 account: string,
70 path: string,
71 extra: string[] = [],
72): Promise<void> {
73 const keygen = await new Deno.Command("ssh-keygen", {
74 args: ["-t", "ed25519", "-N", "", "-f", path, "-q"],
75 stdout: "null",
76 stderr: "null",
77 }).output();
78 assert(keygen.success, "ssh-keygen failed");
79
80 const add = await new Deno.Command(BINARY, {
81 args: ["key", account, `${path}.pub`, ...extra],
82 env: { ZUKA_DATA_DIR: dataDir },
83 stdout: "null",
84 stderr: "null",
85 }).output();
86 assert(add.success, `registering a key for ${account} failed`);
87}
88
89/** Run git over SSH with a specific identity, pinned to this server. */
90function sshEnv(
91 server: Server,
92 keyPath = server.keyPath,
93): Record<string, string> {
94 return {
95 GIT_SSH_COMMAND:
96 `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null` +
97 ` -o LogLevel=ERROR -o IdentitiesOnly=yes -o BatchMode=yes`,
98 };
99}
100
101function sshUrl(server: Server, account: string, repo: string) {
102 return `ssh://git@127.0.0.1:${server.sshPort}/${account}/${repo}.git`;
103}
104
105/** Raw SSH exec, for proving what a session may NOT do. */
106async function sshExec(server: Server, command: string[]): Promise<string> {
107 const { stdout, stderr } = await new Deno.Command("ssh", {
108 args: [
109 "-i",
110 server.keyPath,
111 "-o",
112 "StrictHostKeyChecking=no",
113 "-o",
114 "UserKnownHostsFile=/dev/null",
115 "-o",
116 "LogLevel=ERROR",
117 "-o",
118 "IdentitiesOnly=yes",
119 "-o",
120 "BatchMode=yes",
121 "-p",
122 String(server.sshPort),
123 "git@127.0.0.1",
124 ...command,
125 ],
126 stdout: "piped",
127 stderr: "piped",
128 }).output();
129 const decode = new TextDecoder();
130 return decode.decode(stdout) + decode.decode(stderr);
131}
132
133async function start(extra: Record<string, string> = {}): Promise<Server> {
134 const workDir = await Deno.makeTempDir({ prefix: "zuka-live-" });
135 const dataDir = `${workDir}/data`;
136 await Deno.mkdir(dataDir, { recursive: true });
137
138 const port = await freePort();
139 const sshPort = await freePort();
140 const token = await mintToken(dataDir, "alice");
141 const keyPath = `${workDir}/id_alice`;
142 await registerKey(dataDir, "alice", keyPath);
143
144 const child = new Deno.Command(BINARY, {
145 env: {
146 ZUKA_DATA_DIR: dataDir,
147 ZUKA_BIND: `127.0.0.1:${port}`,
148 ZUKA_SSH_BIND: `127.0.0.1:${sshPort}`,
149 PATH: Deno.env.get("PATH") ?? "/usr/bin:/bin",
150 ...extra,
151 },
152 stdout: "null",
153 // The server logs two lines per request. Left undrained this fills the 64 KiB
154 // pipe and hangs it, so it is discarded rather than piped.
155 stderr: "null",
156 }).spawn();
157
158 // Poll readiness rather than sleeping — a fixed sleep is either slow or flaky.
159 const deadline = Date.now() + 10_000;
160 for (;;) {
161 try {
162 const probe = await fetch(`http://127.0.0.1:${port}/healthz`);
163 await drain(probe);
164 if (probe.ok) break;
165 } catch {
166 // not listening yet
167 }
168 if (Date.now() > deadline) throw new Error("server did not become ready");
169 await new Promise((r) => setTimeout(r, 50));
170 }
171
172 return {
173 port,
174 sshPort,
175 dataDir,
176 workDir,
177 token,
178 keyPath,
179 stop: async () => {
180 try {
181 child.kill("SIGKILL");
182 } catch {
183 // already gone
184 }
185 await child.status;
186 await Deno.remove(workDir, { recursive: true }).catch(() => {});
187 },
188 };
189}
190
191async function git(
192 args: string[],
193 cwd: string,
194 env: Record<string, string> = {},
195): Promise<{ code: number; stdout: string; stderr: string }> {
196 const command = new Deno.Command("git", {
197 args: [
198 "-c",
199 "user.email=agent@example.com",
200 "-c",
201 "user.name=Agent",
202 "-c",
203 "protocol.version=2",
204 ...args,
205 ],
206 cwd,
207 env: { ...Deno.env.toObject(), GIT_TERMINAL_PROMPT: "0", ...env },
208 stdout: "piped",
209 stderr: "piped",
210 });
211 const { code, stdout, stderr } = await command.output();
212 return {
213 code,
214 stdout: new TextDecoder().decode(stdout),
215 stderr: new TextDecoder().decode(stderr),
216 };
217}
218
219function cloneUrl(
220 server: Server,
221 account: string,
222 repo: string,
223 token = server.token,
224) {
225 return `http://x-access-token:${token}@127.0.0.1:${server.port}/${account}/${repo}.git`;
226}
227
228async function api(
229 server: Server,
230 method: string,
231 path: string,
232 options: { token?: string; body?: unknown } = {},
233): Promise<Response> {
234 const headers: Record<string, string> = {};
235 const token = options.token ?? server.token;
236 if (token) headers["authorization"] = `Bearer ${token}`;
237 if (options.body !== undefined) headers["content-type"] = "application/json";
238
239 return await fetch(`http://127.0.0.1:${server.port}${path}`, {
240 method,
241 headers,
242 body: options.body === undefined ? undefined : JSON.stringify(options.body),
243 });
244}
245
246/** Read a response fully, so no test leaves a locked or dangling stream. */
247async function drain(response: Response): Promise<string> {
248 return await response.text();
249}
250
251async function json(response: Response): Promise<
252 // deno-lint-ignore no-explicit-any
253 any
254> {
255 return JSON.parse(await drain(response));
256}
257
258async function createRepo(server: Server, name: string) {
259 const response = await api(server, "POST", "/v1/repos", { body: { name } });
260 const body = await drain(response);
261 assertEquals(response.status, 201, body);
262}
263
264/** A second server with extra configuration, for tests that need a tighter limit. */
265async function startWith(extra: Record<string, string>): Promise<Server> {
266 return await start(extra);
267}
268
269/**
270 * Run the binary as a one-shot command against a running server's data directory.
271 *
272 * This is how an operator or a systemd timer invokes maintenance, so the tests
273 * exercise the same entry point rather than an internal function.
274 */
275async function runCli(
276 server: Server,
277 args: string[],
278 extra: Record<string, string>,
279): Promise<{ code: number; stdout: string; stderr: string }> {
280 const { code, stdout, stderr } = await new Deno.Command(BINARY, {
281 args,
282 env: {
283 ZUKA_DATA_DIR: server.dataDir,
284 PATH: Deno.env.get("PATH") ?? "/usr/bin:/bin",
285 ...extra,
286 },
287 stdout: "piped",
288 stderr: "piped",
289 }).output();
290 const decode = new TextDecoder();
291 return { code, stdout: decode.decode(stdout), stderr: decode.decode(stderr) };
292}
293
294/** Loose objects in a bare repository — what accumulates without gc. */
295async function countLoose(gitDir: string): Promise<number> {
296 let count = 0;
297 for await (const bucket of Deno.readDir(`${gitDir}/objects`)) {
298 if (
299 !bucket.isDirectory || bucket.name === "pack" || bucket.name === "info"
300 ) continue;
301 for await (
302 const object of Deno.readDir(`${gitDir}/objects/${bucket.name}`)
303 ) {
304 if (object.isFile) count++;
305 }
306 }
307 return count;
308}
309
310function live(name: string, fn: (server: Server) => Promise<void>) {
311 Deno.test({
312 name,
313 ignore: !ENABLED,
314 async fn() {
315 const server = await start();
316 try {
317 await fn(server);
318 } finally {
319 await server.stop();
320 }
321 },
322 });
323}
324
325live(
326 "a repository round-trips through clone, commit, push and clone",
327 async (server) => {
328 await createRepo(server, "site");
329
330 const cloned = await git([
331 "clone",
332 cloneUrl(server, "alice", "site"),
333 "work",
334 ], server.workDir);
335 assertEquals(cloned.code, 0, cloned.stderr);
336
337 const repo = `${server.workDir}/work`;
338 await Deno.writeTextFile(`${repo}/README.md`, "hello from an agent\n");
339 assertEquals((await git(["add", "README.md"], repo)).code, 0);
340 assertEquals((await git(["commit", "-m", "add readme"], repo)).code, 0);
341
342 const pushed = await git(["push", "origin", "HEAD:refs/heads/main"], repo);
343 assertEquals(pushed.code, 0, pushed.stderr);
344
345 const verify = await git([
346 "clone",
347 cloneUrl(server, "alice", "site"),
348 "verify",
349 ], server.workDir);
350 assertEquals(verify.code, 0, verify.stderr);
351
352 const content = await Deno.readTextFile(
353 `${server.workDir}/verify/README.md`,
354 );
355 assertEquals(content, "hello from an agent\n");
356 },
357);
358
359live(
360 "the pushed commit is visible over the REST refs endpoint",
361 async (server) => {
362 await createRepo(server, "site");
363 const repo = `${server.workDir}/work`;
364 assertEquals(
365 (await git(
366 ["clone", cloneUrl(server, "alice", "site"), "work"],
367 server.workDir,
368 )).code,
369 0,
370 );
371 await Deno.writeTextFile(`${repo}/a.txt`, "a");
372 await git(["add", "."], repo);
373 await git(["commit", "-m", "a"], repo);
374 await git(["push", "origin", "HEAD:refs/heads/main"], repo);
375
376 const response = await api(server, "GET", "/v1/repos/alice/site/refs");
377 assertEquals(response.status, 200);
378 const body = await json(response);
379 assertEquals(body.items.length, 1);
380 assertEquals(body.items[0].name, "refs/heads/main");
381 assertEquals(body.items[0].type, "branch");
382 assert(
383 /^[0-9a-f]{40}$/.test(body.items[0].sha),
384 "sha must be a full object id",
385 );
386 },
387);
388
389live(
390 "an unauthenticated git request challenges so the credential helper runs",
391 async (server) => {
392 await createRepo(server, "site");
393 const response = await fetch(
394 `http://127.0.0.1:${server.port}/alice/site.git/info/refs?service=git-upload-pack`,
395 );
396 await drain(response);
397
398 assertEquals(response.status, 401);
399 const challenge = response.headers.get("www-authenticate") ?? "";
400 assertStringIncludes(challenge, "Basic realm=");
401 // git renders a JSON error body as noise at the user.
402 assert(
403 !(response.headers.get("content-type") ?? "").includes("problem+json"),
404 "the git paths must not answer with problem+json",
405 );
406 },
407);
408
409live(
410 "another account cannot read, clone or enumerate a repository",
411 async (server) => {
412 await createRepo(server, "site");
413 const bob = await mintToken(server.dataDir, "bob");
414
415 const read = await api(server, "GET", "/v1/repos/alice/site", {
416 token: bob,
417 });
418 await drain(read);
419 assertEquals(read.status, 404, "403 would confirm the repository exists");
420
421 const cloned = await git(
422 ["clone", cloneUrl(server, "alice", "site", bob), "steal"],
423 server.workDir,
424 );
425 assert(cloned.code !== 0, "a foreign account must not clone");
426
427 const list = await api(server, "GET", "/v1/repos", { token: bob });
428 assertEquals((await json(list)).items.length, 0);
429 },
430);
431
432live(
433 "names that would escape the data directory are refused",
434 async (server) => {
435 for (const name of ["../../etc", ".git", "a/b", "", "-leading", "x.lock"]) {
436 const response = await api(server, "POST", "/v1/repos", {
437 body: { name },
438 });
439 const body = await json(response);
440 assertEquals(response.status, 400, `${name} must be refused`);
441 assertStringIncludes(body.type, "/problems/invalid-name");
442 }
443 },
444);
445
446live(
447 "only the three smart-HTTP endpoints are routable under .git",
448 async (server) => {
449 await createRepo(server, "site");
450 for (
451 const path of [
452 "/alice/site.git/config",
453 "/alice/site.git/HEAD",
454 "/alice/site.git/objects/info/packs",
455 "/alice/site.git/info/refs",
456 ]
457 ) {
458 const response = await api(server, "GET", path);
459 await drain(response);
460 assertEquals(response.status, 404, `${path} must not be served`);
461 }
462 },
463);
464
465live(
466 "creating the same repository twice conflicts, including by case",
467 async (server) => {
468 await createRepo(server, "site");
469
470 const same = await api(server, "POST", "/v1/repos", {
471 body: { name: "site" },
472 });
473 assertEquals(same.status, 409);
474 assertStringIncludes((await json(same)).type, "/problems/repo-exists");
475
476 const cased = await api(server, "POST", "/v1/repos", {
477 body: { name: "Site" },
478 });
479 assertEquals(
480 cased.status,
481 409,
482 "a case variant would collide on a case-insensitive disk",
483 );
484 await drain(cased);
485 },
486);
487
488live(
489 "a deleted repository stops serving and is recoverable on disk",
490 async (server) => {
491 await createRepo(server, "site");
492
493 const deleted = await api(server, "DELETE", "/v1/repos/alice/site");
494 await drain(deleted);
495 assertEquals(deleted.status, 204);
496
497 const gone = await api(server, "GET", "/v1/repos/alice/site");
498 await drain(gone);
499 assertEquals(gone.status, 404);
500
501 const again = await api(server, "DELETE", "/v1/repos/alice/site");
502 await drain(again);
503 assertEquals(
504 again.status,
505 404,
506 "deleting an absent repo must not report success",
507 );
508
509 const graves = [...Deno.readDirSync(`${server.dataDir}/tmp/deleted`)];
510 assertEquals(
511 graves.length,
512 1,
513 "delete must move the repo aside, not remove it",
514 );
515 },
516);
517
518live("health reports mode and disk headroom", async (server) => {
519 const response = await api(server, "GET", "/healthz");
520 const body = await json(response);
521 assertEquals(response.status, 200);
522 assertEquals(body.status, "ok");
523 assertEquals(body.mode, "standalone");
524 assert(typeof body.disk_free_bytes === "number");
525});
526
527// ── SSH transport ──────────────────────────────────────────────────────────
528
529live("a repository round-trips over SSH", async (server) => {
530 await createRepo(server, "site");
531 const env = sshEnv(server);
532
533 const cloned = await git(
534 ["clone", sshUrl(server, "alice", "site"), "work"],
535 server.workDir,
536 env,
537 );
538 assertEquals(cloned.code, 0, cloned.stderr);
539
540 const repo = `${server.workDir}/work`;
541 await Deno.writeTextFile(`${repo}/README.md`, "over ssh\n");
542 await git(["add", "."], repo, env);
543 await git(["commit", "-m", "add readme"], repo, env);
544
545 const pushed = await git(
546 ["push", "origin", "HEAD:refs/heads/main"],
547 repo,
548 env,
549 );
550 assertEquals(pushed.code, 0, pushed.stderr);
551
552 // Fetching a repository that HAS content is the case that deadlocks if the
553 // client-to-stdin copy is joined on rather than pumped independently.
554 const verify = await git(
555 ["clone", sshUrl(server, "alice", "site"), "verify"],
556 server.workDir,
557 env,
558 );
559 assertEquals(verify.code, 0, verify.stderr);
560 assertEquals(
561 await Deno.readTextFile(`${server.workDir}/verify/README.md`),
562 "over ssh\n",
563 );
564});
565
566live(
567 "an SSH session cannot open a shell or run anything but git",
568 async (server) => {
569 await createRepo(server, "site");
570
571 assertStringIncludes(await sshExec(server, []), "not a shell");
572
573 for (
574 const command of [
575 ["cat /etc/passwd"],
576 ["scp -t /tmp"],
577 ["git-daemon"],
578 ]
579 ) {
580 const output = await sshExec(server, command);
581 assertStringIncludes(
582 output,
583 `${NAME}:`,
584 `${command} must be refused by us`,
585 );
586 assert(
587 !output.includes("root:"),
588 "a refused command must not have executed",
589 );
590 }
591 },
592);
593
594live(
595 "SSH refuses command injection and traversal in the repository path",
596 async (server) => {
597 await createRepo(server, "site");
598
599 for (
600 const command of [
601 "git-upload-pack '/alice/site.git'; id",
602 "git-upload-pack '/alice/site.git' && id",
603 "git-upload-pack '/alice/$(id).git'",
604 "git-upload-pack '/alice/../../etc/passwd'",
605 "git-upload-pack '//etc/shadow'",
606 "git-upload-pack '/a/b/c.git'",
607 ]
608 ) {
609 const output = await sshExec(server, [command]);
610 assertStringIncludes(output, `${NAME}:`, `${command} must be refused`);
611 assert(!output.includes("uid="), "injected command must not have run");
612 }
613 },
614);
615
616live("an unregistered SSH key cannot authenticate", async (server) => {
617 await createRepo(server, "site");
618
619 const rogue = `${server.workDir}/rogue`;
620 await new Deno.Command("ssh-keygen", {
621 args: ["-t", "ed25519", "-N", "", "-f", rogue, "-q"],
622 stdout: "null",
623 stderr: "null",
624 }).output();
625
626 const cloned = await git(
627 ["clone", sshUrl(server, "alice", "site"), "stolen"],
628 server.workDir,
629 sshEnv(server, rogue),
630 );
631 assert(cloned.code !== 0, "an unregistered key must not authenticate");
632});
633
634live("a read-only SSH key may clone but not push", async (server) => {
635 await createRepo(server, "site");
636
637 const readOnly = `${server.workDir}/id_readonly`;
638 await registerKey(server.dataDir, "alice", readOnly, ["--read-only"]);
639 const env = sshEnv(server, readOnly);
640
641 const cloned = await git(
642 ["clone", sshUrl(server, "alice", "site"), "ro"],
643 server.workDir,
644 env,
645 );
646 assertEquals(cloned.code, 0, cloned.stderr);
647
648 const repo = `${server.workDir}/ro`;
649 await Deno.writeTextFile(`${repo}/a.txt`, "a");
650 await git(["add", "."], repo, env);
651 await git(["commit", "-m", "a"], repo, env);
652
653 const pushed = await git(
654 ["push", "origin", "HEAD:refs/heads/main"],
655 repo,
656 env,
657 );
658 assert(pushed.code !== 0, "a read-only key must not push");
659});
660
661live(
662 "branch protection is enforced over SSH by the same rule",
663 async (server) => {
664 await createRepo(server, "site");
665 const env = sshEnv(server);
666 const repo = `${server.workDir}/work`;
667
668 await git(
669 ["clone", sshUrl(server, "alice", "site"), "work"],
670 server.workDir,
671 env,
672 );
673 await Deno.writeTextFile(`${repo}/a.txt`, "a");
674 await git(["add", "."], repo, env);
675 await git(["commit", "-m", "first"], repo, env);
676 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
677
678 // Unprotected: the rewrite goes through over SSH just as it does over HTTP.
679 await git(["commit", "--amend", "-m", "rewritten"], repo, env);
680 assertEquals(
681 (await git(
682 ["push", "--force", "origin", "HEAD:refs/heads/main"],
683 repo,
684 env,
685 )).code,
686 0,
687 );
688
689 await drain(
690 await api(server, "PATCH", "/v1/repos/alice/site", {
691 body: { protected_refs: ["refs/heads/main"] },
692 }),
693 );
694
695 const before = await json(
696 await api(server, "GET", "/v1/repos/alice/site/refs"),
697 );
698 await git(["commit", "--amend", "-m", "again"], repo, env);
699 const forced = await git(
700 ["push", "--force", "origin", "HEAD:refs/heads/main"],
701 repo,
702 env,
703 );
704
705 assert(forced.code !== 0, "protection must hold over SSH");
706 assertStringIncludes(forced.stderr, "protected");
707
708 const after = await json(
709 await api(server, "GET", "/v1/repos/alice/site/refs"),
710 );
711 assertEquals(after.items[0].sha, before.items[0].sha);
712 },
713);
714
715// ── Regressions ────────────────────────────────────────────────────────────
716
717live(
718 "a case-variant account cannot address another account's repository",
719 async (server) => {
720 await createRepo(server, "site");
721 const bob = await mintToken(server.dataDir, "bob");
722
723 // `Alice` folds to `alice`; bob must not reach it under either spelling.
724 for (const account of ["alice", "Alice", "ALICE"]) {
725 const response = await api(server, "GET", `/v1/repos/${account}/site`, {
726 token: bob,
727 });
728 await drain(response);
729 assertEquals(
730 response.status,
731 404,
732 `${account} must not be readable by bob`,
733 );
734 }
735
736 // The owner reaches the same single repository under any spelling.
737 const canonical = await json(
738 await api(server, "GET", "/v1/repos/Alice/SITE"),
739 );
740 assertEquals(canonical.account, "alice");
741 assertEquals(canonical.name, "site");
742 },
743);
744
745live(
746 "a repo-scoped admin token cannot delete a repository it cannot read",
747 async (server) => {
748 await createRepo(server, "site");
749 await createRepo(server, "secret");
750
751 const confined = await mintToken(server.dataDir, "alice", [
752 "--scopes",
753 "admin",
754 "--repos",
755 "site",
756 ]);
757
758 const read = await api(server, "GET", "/v1/repos/alice/secret", {
759 token: confined,
760 });
761 await drain(read);
762 assertEquals(read.status, 404);
763
764 const deleted = await api(server, "DELETE", "/v1/repos/alice/secret", {
765 token: confined,
766 });
767 await drain(deleted);
768 assertEquals(
769 deleted.status,
770 404,
771 "a token denied read must not be granted destroy",
772 );
773
774 const still = await api(server, "GET", "/v1/repos/alice/secret");
775 await drain(still);
776 assertEquals(still.status, 200, "the repository must still exist");
777 },
778);
779
780live(
781 "a duplicated service parameter is refused rather than resolved differently",
782 async (server) => {
783 await createRepo(server, "site");
784 const readOnly = await mintToken(server.dataDir, "alice", [
785 "--scopes",
786 "repo:read",
787 ]);
788
789 const single = await api(
790 server,
791 "GET",
792 "/alice/site.git/info/refs?service=git-receive-pack",
793 { token: readOnly },
794 );
795 await drain(single);
796 assertEquals(
797 single.status,
798 403,
799 "a reader must not get the push advertisement",
800 );
801
802 // This server takes the first value and the CGI takes the last, so a duplicate
803 // would let the authorization decision and the running service disagree.
804 const duplicated = await api(
805 server,
806 "GET",
807 "/alice/site.git/info/refs?service=git-upload-pack&service=git-receive-pack",
808 { token: readOnly },
809 );
810 const body = await drain(duplicated);
811 assertEquals(
812 duplicated.status,
813 404,
814 "a duplicated service parameter must not route",
815 );
816 assert(
817 !body.includes("receive-pack"),
818 "the write advertisement must not be reachable this way",
819 );
820 },
821);
822
823live("every git error is plain text, not problem+json", async (server) => {
824 await createRepo(server, "site");
825 const readOnly = await mintToken(server.dataDir, "alice", [
826 "--scopes",
827 "repo:read",
828 ]);
829
830 const cases: Array<[string, Record<string, unknown>]> = [
831 ["/alice/site.git/info/refs?service=git-receive-pack", { token: readOnly }],
832 ["/alice/absent.git/info/refs?service=git-upload-pack", {}],
833 ];
834
835 for (const [path, options] of cases) {
836 const response = await api(server, "GET", path, options);
837 const body = await drain(response);
838 assert(response.status >= 400, `${path} should have failed`);
839 assert(
840 !(response.headers.get("content-type") ?? "").includes("problem+json"),
841 `${path} answered problem+json, which git prints at the user`,
842 );
843 assert(!body.trimStart().startsWith("{"), `${path} answered JSON`);
844 }
845});
846
847live("a token expires and stops working", async (server) => {
848 await createRepo(server, "site");
849
850 // `--days 0` expires at the moment it is minted.
851 const expired = await mintToken(server.dataDir, "alice", [
852 "--scopes",
853 "admin",
854 "--days",
855 "0",
856 ]);
857
858 const response = await api(server, "GET", "/v1/repos", { token: expired });
859 await drain(response);
860 assertEquals(response.status, 401, "an expired token must not authenticate");
861});
862
863live("credential files are not world-readable", async (server) => {
864 await createRepo(server, "site");
865
866 for (const file of ["tokens.json", "ssh_keys.json", "ssh_host_ed25519_key"]) {
867 const info = await Deno.stat(`${server.dataDir}/${file}`);
868 const mode = (info.mode ?? 0) & 0o077;
869 assertEquals(mode, 0, `${file} is readable by group or other`);
870 }
871});
872
873// ── Read API ───────────────────────────────────────────────────────────────
874
875live("repository content is readable over REST", async (server) => {
876 await createRepo(server, "site");
877 const env = sshEnv(server);
878 const repo = `${server.workDir}/work`;
879
880 await git(
881 ["clone", sshUrl(server, "alice", "site"), "work"],
882 server.workDir,
883 env,
884 );
885 await Deno.mkdir(`${repo}/src`, { recursive: true });
886 await Deno.writeTextFile(`${repo}/README.md`, "# hello\n");
887 await Deno.writeTextFile(`${repo}/src/main.rs`, "fn main() {}\n");
888 await git(["add", "."], repo, env);
889 await git(["commit", "-m", "initial"], repo, env);
890 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
891
892 const tree = await json(
893 await api(server, "GET", "/v1/repos/alice/site/tree"),
894 );
895 const names = tree.items.map((e: { name: string }) => e.name).sort();
896 assertEquals(names, ["README.md", "src"]);
897
898 const nested = await json(
899 await api(server, "GET", "/v1/repos/alice/site/tree/src"),
900 );
901 assertEquals(nested.items[0].path, "src/main.rs");
902 assertEquals(nested.items[0].type, "file");
903
904 const raw = await api(server, "GET", "/v1/repos/alice/site/raw/README.md");
905 const body = await drain(raw);
906 assertEquals(body, "# hello\n");
907 assertEquals(raw.headers.get("content-type"), "application/octet-stream");
908 assertEquals(raw.headers.get("x-content-type-options"), "nosniff");
909
910 const log = await json(
911 await api(server, "GET", "/v1/repos/alice/site/commits"),
912 );
913 assertEquals(log.items.length, 1);
914 assertEquals(log.items[0].message, "initial");
915 assert(/^[0-9a-f]{40}$/.test(log.items[0].sha));
916});
917
918live(
919 "read endpoints refuse traversal and smuggled git arguments",
920 async (server) => {
921 await createRepo(server, "site");
922
923 for (
924 const path of [
925 "/v1/repos/alice/site/raw/../../../etc/passwd",
926 "/v1/repos/alice/site/tree/.git/config",
927 "/v1/repos/alice/site/commits?ref=--upload-pack%3Devil",
928 "/v1/repos/alice/site/commits/--help",
929 "/v1/repos/alice/site/commits?limit=999999",
930 ]
931 ) {
932 const response = await api(server, "GET", path);
933 await drain(response);
934 assert(
935 response.status >= 400,
936 `${path} must be refused, got ${response.status}`,
937 );
938 }
939 },
940);
941
942live(
943 "the OpenAPI document is served and matches the crate version",
944 async (server) => {
945 const response = await api(server, "GET", "/openapi.json");
946 const doc = await json(response);
947 assertEquals(response.status, 200);
948 assertEquals(doc.openapi, "3.1.0");
949 assert(
950 doc.paths["/v1/repos"],
951 "the document must describe the repo collection",
952 );
953 },
954);
955
956live(
957 "the product name is not baked into behaviour a rename would break",
958 async (server) => {
959 // Problem type URIs are built from one place and are configurable, so a rename
960 // or a self-hoster's own docs origin does not require touching call sites.
961 const response = await api(server, "POST", "/v1/repos", {
962 body: { name: "../escape" },
963 });
964 const body = await json(response);
965 assertEquals(response.status, 400);
966 assertStringIncludes(body.type, "/problems/invalid-name");
967
968 // The auth realm is derived too.
969 const challenge = await fetch(`http://127.0.0.1:${server.port}/v1/repos`);
970 await challenge.text();
971 assertStringIncludes(
972 challenge.headers.get("www-authenticate") ?? "",
973 "realm=",
974 );
975 },
976);
977
978live("repository settings are patchable", async (server) => {
979 await createRepo(server, "site");
980
981 const described = await json(
982 await api(server, "PATCH", "/v1/repos/alice/site", {
983 body: { description: "the shop" },
984 }),
985 );
986 assertEquals(described.description, "the shop");
987
988 // merge-patch: null clears, absent leaves alone.
989 const cleared = await json(
990 await api(server, "PATCH", "/v1/repos/alice/site", {
991 body: { description: null },
992 }),
993 );
994 assertEquals(cleared.description, undefined);
995 assertEquals(cleared.name, "site", "an absent field must be untouched");
996
997 // A default branch that does not exist would leave clones detached.
998 const bad = await api(server, "PATCH", "/v1/repos/alice/site", {
999 body: { default_branch: "refs/heads/nope" },
1000 });
1001 await drain(bad);
1002 assertEquals(bad.status, 409);
1003});
1004
1005// ── git, at full power ─────────────────────────────────────────────────────
1006
1007live("git archive --remote works over SSH", async (server) => {
1008 await createRepo(server, "site");
1009 const env = sshEnv(server);
1010 const repo = `${server.workDir}/work`;
1011
1012 await git(
1013 ["clone", sshUrl(server, "alice", "site"), "work"],
1014 server.workDir,
1015 env,
1016 );
1017 await Deno.writeTextFile(`${repo}/README.md`, "archived\n");
1018 await git(["add", "."], repo, env);
1019 await git(["commit", "-m", "initial"], repo, env);
1020 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1021
1022 const archived = await git(
1023 [
1024 "archive",
1025 "--remote",
1026 sshUrl(server, "alice", "site"),
1027 "--format=tar",
1028 "-o",
1029 `${server.workDir}/out.tar`,
1030 "refs/heads/main",
1031 ],
1032 server.workDir,
1033 env,
1034 );
1035 assertEquals(archived.code, 0, archived.stderr);
1036
1037 const info = await Deno.stat(`${server.workDir}/out.tar`);
1038 assert(info.size > 0, "the archive must contain something");
1039});
1040
1041live("a read-only key may archive but still may not push", async (server) => {
1042 await createRepo(server, "site");
1043 const env = sshEnv(server);
1044 const repo = `${server.workDir}/work`;
1045 await git(
1046 ["clone", sshUrl(server, "alice", "site"), "work"],
1047 server.workDir,
1048 env,
1049 );
1050 await Deno.writeTextFile(`${repo}/a.txt`, "a");
1051 await git(["add", "."], repo, env);
1052 await git(["commit", "-m", "a"], repo, env);
1053 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1054
1055 const readOnly = `${server.workDir}/id_ro_archive`;
1056 await registerKey(server.dataDir, "alice", readOnly, ["--read-only"]);
1057 const roEnv = sshEnv(server, readOnly);
1058
1059 const archived = await git(
1060 [
1061 "archive",
1062 "--remote",
1063 sshUrl(server, "alice", "site"),
1064 "--format=tar",
1065 "-o",
1066 `${server.workDir}/ro.tar`,
1067 "refs/heads/main",
1068 ],
1069 server.workDir,
1070 roEnv,
1071 );
1072 assertEquals(archived.code, 0, "archiving is a read and must be allowed");
1073});
1074
1075// ── discovery ──────────────────────────────────────────────────────────────
1076
1077/** Seed a repository with two commits and a small tree. */
1078async function seed(server: Server, name = "site") {
1079 await createRepo(server, name);
1080 const env = sshEnv(server);
1081 const repo = `${server.workDir}/${name}-work`;
1082 await git(
1083 ["clone", sshUrl(server, "alice", name), `${name}-work`],
1084 server.workDir,
1085 env,
1086 );
1087
1088 await Deno.mkdir(`${repo}/src`, { recursive: true });
1089 await Deno.writeTextFile(`${repo}/README.md`, "# project\nneedle-one\n");
1090 await Deno.writeTextFile(`${repo}/src/main.rs`, "fn main() {}\n");
1091 await git(["add", "."], repo, env);
1092 await git(["commit", "-m", "initial"], repo, env);
1093 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1094
1095 await Deno.writeTextFile(
1096 `${repo}/src/main.rs`,
1097 "fn main() {\n // needle-two\n}\n",
1098 );
1099 await git(["add", "."], repo, env);
1100 await git(["commit", "-m", "second"], repo, env);
1101 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1102
1103 return { repo, env };
1104}
1105
1106live("compare reports what changed between two points", async (server) => {
1107 await seed(server);
1108 const log = await json(
1109 await api(server, "GET", "/v1/repos/alice/site/commits"),
1110 );
1111 const [head, base] = log.items.map((c: { sha: string }) => c.sha);
1112
1113 const compared = await json(
1114 await api(
1115 server,
1116 "GET",
1117 `/v1/repos/alice/site/compare?base=${base}&head=${head}`,
1118 ),
1119 );
1120 assertEquals(compared.merge_base, base);
1121 assertEquals(compared.commits.length, 1);
1122 assertEquals(compared.changed.length, 1);
1123 assertEquals(compared.changed[0].path, "src/main.rs");
1124 assert(compared.additions > 0, "an edit should report additions");
1125});
1126
1127live(
1128 "search finds content and refuses to be used as a flag",
1129 async (server) => {
1130 await seed(server);
1131
1132 const found = await json(
1133 await api(server, "GET", "/v1/repos/alice/site/search?q=needle-two"),
1134 );
1135 assertEquals(found.items.length, 1);
1136 assertEquals(found.items[0].path, "src/main.rs");
1137
1138 const scoped = await json(
1139 await api(server, "GET", "/v1/repos/alice/site/search?q=needle&path=src"),
1140 );
1141 assert(
1142 scoped.items.every((m: { path: string }) => m.path.startsWith("src/")),
1143 );
1144
1145 const missing = await json(
1146 await api(server, "GET", "/v1/repos/alice/site/search?q=nothinghere"),
1147 );
1148 assertEquals(
1149 missing.items.length,
1150 0,
1151 "no matches is an answer, not an error",
1152 );
1153
1154 const noQuery = await api(server, "GET", "/v1/repos/alice/site/search");
1155 await drain(noQuery);
1156 assertEquals(noQuery.status, 400);
1157 },
1158);
1159
1160live("blame attributes each line to a commit", async (server) => {
1161 await seed(server);
1162 const blamed = await json(
1163 await api(server, "GET", "/v1/repos/alice/site/blame/src/main.rs"),
1164 );
1165 assertEquals(blamed.path, "src/main.rs");
1166 assert(blamed.items.length >= 3, "every line should be attributed");
1167 assert(/^[0-9a-f]{40}$/.test(blamed.items[0].sha));
1168 assertEquals(blamed.items[0].author, "Agent");
1169 assert(
1170 blamed.items.some((l: { content: string }) =>
1171 l.content.includes("needle-two")
1172 ),
1173 );
1174});
1175
1176live("a path-filtered log reports whether it was truncated", async (server) => {
1177 await seed(server);
1178 const filtered = await json(
1179 await api(server, "GET", "/v1/repos/alice/site/commits?path=src/main.rs"),
1180 );
1181 assertEquals(filtered.items.length, 2);
1182 assertEquals(filtered.truncated, false);
1183});
1184
1185// ── MCP ────────────────────────────────────────────────────────────────────
1186
1187let rpcId = 0;
1188async function rpc(
1189 server: Server,
1190 method: string,
1191 params: unknown = {},
1192 token = server.token,
1193 // deno-lint-ignore no-explicit-any
1194): Promise<any> {
1195 const response = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1196 method: "POST",
1197 headers: {
1198 authorization: `Bearer ${token}`,
1199 "content-type": "application/json",
1200 },
1201 body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
1202 });
1203 return JSON.parse(await response.text());
1204}
1205
1206async function callTool(
1207 server: Server,
1208 name: string,
1209 args: Record<string, unknown> = {},
1210 token = server.token,
1211 // deno-lint-ignore no-explicit-any
1212): Promise<any> {
1213 return await rpc(server, "tools/call", { name, arguments: args }, token);
1214}
1215
1216live(
1217 "MCP handshake advertises tools and identifies the server",
1218 async (server) => {
1219 const init = await rpc(server, "initialize", {});
1220 assertEquals(init.jsonrpc, "2.0");
1221 assert(init.result.protocolVersion, "a protocol version is required");
1222 assert(
1223 init.result.capabilities.tools,
1224 "tools capability must be advertised",
1225 );
1226 assert(init.result.serverInfo.name);
1227
1228 // An agent must be told that writes go through git, not through a tool.
1229 assertStringIncludes(init.result.instructions, "git");
1230
1231 const listed = await rpc(server, "tools/list");
1232 const names = listed.result.tools.map((t: { name: string }) => t.name);
1233 for (
1234 const expected of [
1235 "repo_create",
1236 "key_add",
1237 "tree_list",
1238 "search",
1239 "blame",
1240 "compare",
1241 ]
1242 ) {
1243 assert(names.includes(expected), `${expected} must be offered`);
1244 }
1245 for (const banned of ["file_write", "commit_create", "push"]) {
1246 assert(!names.includes(banned), `${banned} would be a second write path`);
1247 }
1248 },
1249);
1250
1251live(
1252 "MCP handles unknown methods and notifications correctly",
1253 async (server) => {
1254 const unknown = await rpc(server, "nonsense/method");
1255 assertEquals(unknown.error.code, -32601);
1256
1257 // A notification carries no id and takes no reply.
1258 const notified = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1259 method: "POST",
1260 headers: {
1261 authorization: `Bearer ${server.token}`,
1262 "content-type": "application/json",
1263 },
1264 body: JSON.stringify({
1265 jsonrpc: "2.0",
1266 method: "notifications/initialized",
1267 }),
1268 });
1269 await notified.text();
1270 assertEquals(notified.status, 202);
1271
1272 const unauthenticated = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1273 method: "POST",
1274 headers: { "content-type": "application/json" },
1275 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
1276 });
1277 await unauthenticated.text();
1278 assertEquals(unauthenticated.status, 401);
1279 },
1280);
1281
1282live(
1283 "an agent provisions and inspects a repository entirely over MCP",
1284 async (server) => {
1285 // repo_create → key_add → git push → discovery. The whole product loop.
1286 const created = await callTool(server, "repo_create", { name: "shop" });
1287 const view = created.result.structuredContent;
1288 assertEquals(view.name, "shop");
1289 assert(view.clone_url_ssh, "the agent needs somewhere to push");
1290
1291 const keyPath = `${server.workDir}/id_mcp`;
1292 await new Deno.Command("ssh-keygen", {
1293 args: ["-t", "ed25519", "-N", "", "-f", keyPath, "-q"],
1294 stdout: "null",
1295 stderr: "null",
1296 }).output();
1297 const pub = await Deno.readTextFile(`${keyPath}.pub`);
1298
1299 const added = await callTool(server, "key_add", {
1300 title: "agent",
1301 key: pub,
1302 });
1303 assert(added.result.structuredContent.fingerprint.startsWith("SHA256:"));
1304
1305 // The key just registered must actually work for git.
1306 const env = sshEnv(server, keyPath);
1307 const repo = `${server.workDir}/shop`;
1308 assertEquals(
1309 (await git(
1310 ["clone", sshUrl(server, "alice", "shop"), "shop"],
1311 server.workDir,
1312 env,
1313 )).code,
1314 0,
1315 "a key registered over MCP must authenticate over SSH",
1316 );
1317 await Deno.writeTextFile(`${repo}/price.txt`, "10\n");
1318 await git(["add", "."], repo, env);
1319 await git(["commit", "-m", "add price"], repo, env);
1320 assertEquals(
1321 (await git(["push", "origin", "HEAD:refs/heads/main"], repo, env)).code,
1322 0,
1323 );
1324
1325 // Discovery sees what git pushed.
1326 const tree = await callTool(server, "tree_list", { repo: "shop" });
1327 const names = tree.result.structuredContent.items.map((
1328 e: { name: string },
1329 ) => e.name);
1330 assert(names.includes("price.txt"));
1331
1332 const read = await callTool(server, "file_read", {
1333 repo: "shop",
1334 path: "price.txt",
1335 });
1336 assertEquals(read.result.structuredContent.content, "10\n");
1337
1338 const refs = await callTool(server, "ref_list", { repo: "shop" });
1339 assertEquals(
1340 refs.result.structuredContent.items[0].name,
1341 "refs/heads/main",
1342 );
1343
1344 const listed = await callTool(server, "repo_list");
1345 const repos = listed.result.structuredContent.items.map((
1346 r: { name: string },
1347 ) => r.name);
1348 assert(repos.includes("shop"));
1349 },
1350);
1351
1352live(
1353 "MCP tool errors come back as tool results, not protocol failures",
1354 async (server) => {
1355 await createRepo(server, "site");
1356
1357 // A model should see the failure and adapt; a protocol error looks like a bug.
1358 const missing = await callTool(server, "file_read", {
1359 repo: "site",
1360 path: "nope.txt",
1361 });
1362 assertEquals(missing.result.isError, true);
1363 assertEquals(missing.error, undefined);
1364
1365 const badArgs = await callTool(server, "tree_list", {});
1366 assertEquals(badArgs.result.isError, true);
1367
1368 const unknownTool = await callTool(server, "not_a_tool", {});
1369 assertEquals(unknownTool.result.isError, true);
1370 },
1371);
1372
1373live("MCP enforces the same access rules as REST", async (server) => {
1374 await createRepo(server, "site");
1375 const bob = await mintToken(server.dataDir, "bob");
1376
1377 const foreign = await callTool(server, "repo_get", { repo: "site" }, bob);
1378 assertEquals(
1379 foreign.result.isError,
1380 true,
1381 "bob must not read alice's repository",
1382 );
1383
1384 const readOnly = await mintToken(server.dataDir, "alice", [
1385 "--scopes",
1386 "repo:read",
1387 ]);
1388 const denied = await callTool(
1389 server,
1390 "repo_create",
1391 { name: "nope" },
1392 readOnly,
1393 );
1394 assertEquals(denied.result.isError, true, "a read token must not create");
1395
1396 // Key management requires an unconfined admin token, as it does over REST.
1397 const confined = await mintToken(server.dataDir, "alice", [
1398 "--scopes",
1399 "admin",
1400 "--repos",
1401 "site",
1402 ]);
1403 const keys = await callTool(server, "key_list", {}, confined);
1404 assertEquals(keys.result.isError, true);
1405});
1406
1407live(
1408 "MCP discovery refuses traversal and smuggled git arguments",
1409 async (server) => {
1410 await seed(server);
1411
1412 for (
1413 const [tool, args] of [
1414 ["file_read", { repo: "site", path: "../../../etc/passwd" }],
1415 ["tree_list", { repo: "site", path: ".git" }],
1416 ["commit_log", { repo: "site", ref: "--upload-pack=evil" }],
1417 ["commit_get", { repo: "site", sha: "--help" }],
1418 ["compare", { repo: "site", base: "HEAD", head: "HEAD" }],
1419 ["blame", { repo: "site", path: ".git/config" }],
1420 ] as Array<[string, Record<string, unknown>]>
1421 ) {
1422 const result = await callTool(server, tool, args);
1423 assertEquals(
1424 result.result.isError,
1425 true,
1426 `${tool} must refuse ${JSON.stringify(args)}`,
1427 );
1428 }
1429 },
1430);
1431
1432// ── M4: limits, quotas and maintenance ─────────────────────────────────────
1433
1434live(
1435 "an over-quota account cannot create more repositories",
1436 async (server) => {
1437 // The harness server runs with the default repo cap; assert against what the
1438 // account endpoint reports rather than a number baked into the test.
1439 const account = await json(await api(server, "GET", "/v1/account"));
1440 assert(account.limits.max_repos > 0, "a repo cap must be configured");
1441 assertEquals(account.usage.repos, 0);
1442
1443 await createRepo(server, "one");
1444 const after = await json(await api(server, "GET", "/v1/account"));
1445 assertEquals(after.usage.repos, 1, "usage must track creation");
1446 },
1447);
1448
1449live(
1450 "quota enforcement is reported as a problem with limit and usage",
1451 async (server) => {
1452 // A dedicated server with a cap of one repository.
1453 const tight = await startWith({ ZUKA_MAX_REPOS_PER_ACCOUNT: "1" });
1454 try {
1455 await createRepo(tight, "first");
1456
1457 const refused = await api(tight, "POST", "/v1/repos", {
1458 body: { name: "second" },
1459 });
1460 const problem = await json(refused);
1461 assertEquals(refused.status, 413);
1462 assertStringIncludes(problem.type, "/problems/quota-exceeded");
1463 assertEquals(problem.limit, 1);
1464 assertEquals(problem.used, 1);
1465
1466 // Reads keep working: over-quota blocks writes only.
1467 const readable = await api(tight, "GET", "/v1/repos/alice/first");
1468 await drain(readable);
1469 assertEquals(readable.status, 200, "over-quota must not break reads");
1470 } finally {
1471 await tight.stop();
1472 }
1473 },
1474);
1475
1476live("a rate-limited caller is refused with Retry-After", async (_server) => {
1477 const limited = await startWith({ ZUKA_RATE_PER_MINUTE: "5" });
1478 try {
1479 // Set the repository up before spending the allowance.
1480 await createRepo(limited, "site");
1481
1482 let refused: Response | undefined;
1483 for (let i = 0; i < 20; i++) {
1484 const response = await api(limited, "GET", "/v1/account");
1485 await drain(response);
1486 if (response.status === 429) {
1487 refused = response;
1488 break;
1489 }
1490 }
1491 assert(refused, "a caller must eventually be rate limited");
1492 assert(
1493 refused.headers.get("retry-after"),
1494 "a 429 must say when to come back",
1495 );
1496
1497 // The git transport is deliberately NOT rate limited: a clone is one long
1498 // request, already bounded by the concurrency semaphore, and a per-minute
1499 // count is the wrong instrument for it.
1500 const cloned = await git(
1501 ["clone", cloneUrl(limited, "alice", "site"), "still-works"],
1502 limited.workDir,
1503 );
1504 assertEquals(
1505 cloned.code,
1506 0,
1507 `git must keep working while the API is rate limited: ${cloned.stderr}`,
1508 );
1509 } finally {
1510 await limited.stop();
1511 }
1512});
1513
1514live(
1515 "gc reclaims unreachable objects and leaves the repository intact",
1516 async (server) => {
1517 const { repo, env } = await seed(server, "churn");
1518 const gitDir = `${server.dataDir}/git/alice/churn.git`;
1519
1520 // Rewriting a branch orphans the objects the old tip referenced.
1521 await Deno.writeTextFile(
1522 `${repo}/src/main.rs`,
1523 "fn main() { /* rewritten */ }\n",
1524 );
1525 await git(["add", "."], repo, env);
1526 await git(["commit", "--amend", "-m", "rewritten"], repo, env);
1527 await git(["push", "--force", "origin", "HEAD:refs/heads/main"], repo, env);
1528
1529 const before = await countLoose(gitDir);
1530 assert(before > 0, "there should be loose objects to collect");
1531
1532 // Zero grace so the test does not have to wait two weeks.
1533 const gc = await runCli(server, ["gc"], { ZUKA_GC_PRUNE_GRACE: "now" });
1534 assertEquals(gc.code, 0, gc.stderr);
1535 assertStringIncludes(gc.stderr, "repacked");
1536
1537 const intact = await new Deno.Command("git", {
1538 args: [
1539 "--git-dir",
1540 gitDir,
1541 "fsck",
1542 "--no-progress",
1543 "--connectivity-only",
1544 ],
1545 stdout: "null",
1546 stderr: "piped",
1547 }).output();
1548 assertEquals(intact.code, 0, "gc must not corrupt the repository");
1549
1550 // The repository still serves after collection.
1551 const cloned = await git(
1552 ["clone", sshUrl(server, "alice", "churn"), "after-gc"],
1553 server.workDir,
1554 env,
1555 );
1556 assertEquals(cloned.code, 0, cloned.stderr);
1557 },
1558);
1559
1560live("gc runs safely while a clone is in flight", async (server) => {
1561 const { repo, env } = await seed(server, "busy");
1562
1563 // Make the repository big enough that the clone is still running when gc starts.
1564 // getRandomValues caps at 64 KiB per call, so fill in chunks. Incompressible so
1565 // the pack cannot be shrunk away and the clone actually takes time.
1566 const big = new Uint8Array(12 * 1024 * 1024);
1567 for (let offset = 0; offset < big.length; offset += 65536) {
1568 crypto.getRandomValues(
1569 big.subarray(offset, Math.min(offset + 65536, big.length)),
1570 );
1571 }
1572 await Deno.writeFile(`${repo}/blob.bin`, big);
1573 await git(["add", "."], repo, env);
1574 await git(["commit", "-m", "big"], repo, env);
1575 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1576
1577 const cloning = git(
1578 ["clone", sshUrl(server, "alice", "busy"), "concurrent"],
1579 server.workDir,
1580 env,
1581 );
1582 // Default grace, which is what makes this safe — not a lock.
1583 const collecting = runCli(server, ["gc"], {});
1584
1585 const [cloned, gc] = await Promise.all([cloning, collecting]);
1586 assertEquals(gc.code, 0, gc.stderr);
1587 assertEquals(
1588 cloned.code,
1589 0,
1590 `a clone must survive concurrent gc: ${cloned.stderr}`,
1591 );
1592
1593 const size = (await Deno.stat(`${server.workDir}/concurrent/blob.bin`)).size;
1594 assertEquals(size, big.length, "the cloned content must be complete");
1595});
1596
1597live("sweep reclaims deleted repositories once they expire", async (server) => {
1598 await createRepo(server, "doomed");
1599 await drain(await api(server, "DELETE", "/v1/repos/alice/doomed"));
1600
1601 const graves = () =>
1602 [...Deno.readDirSync(`${server.dataDir}/tmp/deleted`)].length;
1603 assertEquals(graves(), 1, "delete must keep the repository recoverable");
1604
1605 // Retention still running: nothing is reclaimed.
1606 const kept = await runCli(server, ["sweep"], {
1607 ZUKA_DELETED_RETENTION_DAYS: "7",
1608 });
1609 assertEquals(kept.code, 0);
1610 assertEquals(graves(), 1, "a fresh delete must stay recoverable");
1611
1612 const swept = await runCli(server, ["sweep"], {
1613 ZUKA_DELETED_RETENTION_DAYS: "0",
1614 });
1615 assertEquals(swept.code, 0, swept.stderr);
1616 assertStringIncludes(swept.stderr, "removed 1");
1617 assertEquals(graves(), 0, "an expired delete must be reclaimed");
1618});
1619
1620live("fsck finds an orphan in both directions", async (server) => {
1621 await createRepo(server, "paired");
1622 const clean = await runCli(server, ["fsck"], {});
1623 assertEquals(clean.code, 0, "a consistent store must exit zero");
1624
1625 // Metadata without a repository: what a crash mid-delete leaves.
1626 await Deno.remove(`${server.dataDir}/git/alice/paired.git`, {
1627 recursive: true,
1628 });
1629 const dangling = await runCli(server, ["fsck"], {});
1630 assertEquals(
1631 dangling.code,
1632 1,
1633 "an orphan must be reported as a non-zero exit",
1634 );
1635 assertStringIncludes(dangling.stdout, "record-without-repo");
1636
1637 // Repair removes the record, and the result is durable.
1638 const repaired = await runCli(server, ["fsck", "--repair"], {});
1639 assertEquals(repaired.code, 0);
1640 assertStringIncludes(repaired.stderr, "1 repaired");
1641 assertEquals((await runCli(server, ["fsck"], {})).code, 0);
1642
1643 // A repository without metadata: reported, never deleted.
1644 await createRepo(server, "lonely");
1645 await Deno.remove(`${server.dataDir}/meta/alice/lonely.json`);
1646 const lonely = await runCli(server, ["fsck", "--repair"], {});
1647 assertStringIncludes(lonely.stdout, "repo-without-record");
1648 assert(
1649 await Deno.stat(`${server.dataDir}/git/alice/lonely.git/HEAD`).then(() =>
1650 true
1651 ),
1652 "repair must never delete real repository bytes",
1653 );
1654});
1655
1656live(
1657 "newly created repositories do not run gc inside a push",
1658 async (server) => {
1659 await createRepo(server, "quiet");
1660 const gitDir = `${server.dataDir}/git/alice/quiet.git`;
1661
1662 for (
1663 const [key, expected] of [["gc.auto", "0"], ["receive.autogc", "false"]]
1664 ) {
1665 const value = await new Deno.Command("git", {
1666 args: ["--git-dir", gitDir, "config", "--get", key],
1667 stdout: "piped",
1668 stderr: "null",
1669 }).output();
1670 assertEquals(
1671 new TextDecoder().decode(value.stdout).trim(),
1672 expected,
1673 `${key} must be off so maintenance is scheduled, not opportunistic`,
1674 );
1675 }
1676 },
1677);
1678
1679// ── M5: CI ─────────────────────────────────────────────────────────────────
1680
1681/** Push a repository carrying a CI spec, on a server with CI enabled. */
1682async function seedCi(server: Server, name: string, spec: string) {
1683 await createRepo(server, name);
1684 const env = sshEnv(server);
1685 const repo = `${server.workDir}/${name}-ci`;
1686 await git(
1687 ["clone", sshUrl(server, "alice", name), `${name}-ci`],
1688 server.workDir,
1689 env,
1690 );
1691
1692 await Deno.writeTextFile(`${repo}/.zuka.toml`, spec);
1693 await git(["add", "."], repo, env);
1694 await git(["commit", "-m", "add ci"], repo, env);
1695 const pushed = await git(
1696 ["push", "origin", "HEAD:refs/heads/main"],
1697 repo,
1698 env,
1699 );
1700 assertEquals(pushed.code, 0, pushed.stderr);
1701 return { repo, env };
1702}
1703
1704/** Poll until a run reaches a terminal state. */
1705async function settle(server: Server, repo: string, timeoutMs = 30_000) {
1706 const deadline = Date.now() + timeoutMs;
1707 for (;;) {
1708 const body = await json(
1709 await api(server, "GET", `/v1/repos/alice/${repo}/runs`),
1710 );
1711 const run = body.items[0];
1712 if (run && !["queued", "running"].includes(run.status)) return run;
1713 if (Date.now() > deadline) {
1714 throw new Error(`run did not settle: ${JSON.stringify(body.items)}`);
1715 }
1716 await new Promise((r) => setTimeout(r, 200));
1717 }
1718}
1719
1720live(
1721 "a push triggers a run that succeeds and captures output",
1722 async (_server) => {
1723 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1724 try {
1725 await seedCi(
1726 ci,
1727 "green",
1728 '[run]\nsteps = ["echo hello-from-ci", "true"]\n',
1729 );
1730
1731 const run = await settle(ci, "green");
1732 assertEquals(run.status, "succeeded");
1733 assertEquals(run.exit_code, 0);
1734 assertEquals(run.git_ref, "refs/heads/main");
1735 assert(/^[0-9a-f]{40}$/.test(run.sha));
1736
1737 const logs = await drain(
1738 await api(ci, "GET", `/v1/repos/alice/green/runs/${run.id}/logs`),
1739 );
1740 assertStringIncludes(logs, "hello-from-ci");
1741 assertStringIncludes(logs, "all steps succeeded");
1742 } finally {
1743 await ci.stop();
1744 }
1745 },
1746);
1747
1748live(
1749 "a failing step fails the run and stops the remaining steps",
1750 async (_server) => {
1751 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1752 try {
1753 await seedCi(
1754 ci,
1755 "red",
1756 '[run]\nsteps = ["echo first", "exit 3", "echo never-runs"]\n',
1757 );
1758
1759 const run = await settle(ci, "red");
1760 assertEquals(run.status, "failed");
1761 assertEquals(run.exit_code, 3);
1762
1763 const logs = await drain(
1764 await api(ci, "GET", `/v1/repos/alice/red/runs/${run.id}/logs`),
1765 );
1766 assertStringIncludes(logs, "first");
1767 assert(!logs.includes("never-runs"), "a failed step must stop the run");
1768 } finally {
1769 await ci.stop();
1770 }
1771 },
1772);
1773
1774live("a run that exceeds its timeout is killed", async (_server) => {
1775 const ci = await startWith({
1776 ZUKA_CI_ENABLED: "1",
1777 ZUKA_CI_TIMEOUT_SECS: "2",
1778 });
1779 try {
1780 await seedCi(ci, "slow", '[run]\nsteps = ["sleep 60"]\ntimeout_secs = 2\n');
1781
1782 const run = await settle(ci, "slow", 30_000);
1783 assertEquals(run.status, "timed_out");
1784
1785 // The whole process group must be gone, not just the shell.
1786 const survivors = await new Deno.Command("pgrep", {
1787 args: ["-f", "sleep 60"],
1788 stdout: "piped",
1789 })
1790 .output();
1791 assertEquals(
1792 new TextDecoder().decode(survivors.stdout).trim(),
1793 "",
1794 "a timeout must kill the process group, not orphan it",
1795 );
1796 } finally {
1797 await ci.stop();
1798 }
1799});
1800
1801live("CI steps cannot read the server's environment", async (_server) => {
1802 const ci = await startWith({
1803 ZUKA_CI_ENABLED: "1",
1804 // A secret the service process holds. A step must not see it.
1805 ZUKA_TEST_SECRET: "hunter2",
1806 });
1807 try {
1808 await seedCi(ci, "envtest", '[run]\nsteps = ["env | sort"]\n');
1809
1810 const run = await settle(ci, "envtest");
1811 const logs = await drain(
1812 await api(ci, "GET", `/v1/repos/alice/envtest/runs/${run.id}/logs`),
1813 );
1814
1815 assert(
1816 !logs.includes("hunter2"),
1817 "the service's environment must be scrubbed",
1818 );
1819 assertStringIncludes(
1820 logs,
1821 "ZUKA_RUN_ID=",
1822 "a step should know its own run",
1823 );
1824 assertStringIncludes(logs, "PATH=", "a step needs to find its tools");
1825 } finally {
1826 await ci.stop();
1827 }
1828});
1829
1830live("a repository with no spec is a no-op, not a failure", async (_server) => {
1831 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1832 try {
1833 await createRepo(ci, "nospec");
1834 const env = sshEnv(ci);
1835 const repo = `${ci.workDir}/nospec-x`;
1836 await git(
1837 ["clone", sshUrl(ci, "alice", "nospec"), "nospec-x"],
1838 ci.workDir,
1839 env,
1840 );
1841 await Deno.writeTextFile(`${repo}/README.md`, "no ci here\n");
1842 await git(["add", "."], repo, env);
1843 await git(["commit", "-m", "no ci"], repo, env);
1844 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1845
1846 const run = await settle(ci, "nospec");
1847 assertEquals(run.status, "succeeded");
1848 assertEquals(run.detail, "no spec");
1849 } finally {
1850 await ci.stop();
1851 }
1852});
1853
1854live(
1855 "a spec may lower its timeout but never raise it past the host ceiling",
1856 async (_server) => {
1857 const ci = await startWith({
1858 ZUKA_CI_ENABLED: "1",
1859 ZUKA_CI_TIMEOUT_SECS: "3",
1860 });
1861 try {
1862 // The spec asks for an hour; the host allows three seconds.
1863 await seedCi(
1864 ci,
1865 "greedy",
1866 '[run]\nsteps = ["sleep 60"]\ntimeout_secs = 3600\n',
1867 );
1868
1869 const started = Date.now();
1870 const run = await settle(ci, "greedy", 30_000);
1871 assertEquals(run.status, "timed_out");
1872 assert(
1873 Date.now() - started < 25_000,
1874 "a repository must not be able to hold a runner past the host ceiling",
1875 );
1876 } finally {
1877 await ci.stop();
1878 }
1879 },
1880);
1881
1882live("runs are triggerable and cancellable over the API", async (_server) => {
1883 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1884 try {
1885 await seedCi(ci, "manual", '[run]\nsteps = ["sleep 30"]\n');
1886 await settle(ci, "manual", 40_000).catch(() => {});
1887
1888 const triggered = await api(ci, "POST", `/v1/repos/alice/manual/runs`, {
1889 body: { ref: "refs/heads/main" },
1890 });
1891 const queued = await json(triggered);
1892 assertEquals(triggered.status, 201);
1893 assertEquals(queued.status, "queued");
1894
1895 const cancelled = await json(
1896 await api(ci, "PATCH", `/v1/repos/alice/manual/runs/${queued.id}`, {
1897 body: { status: "cancelled" },
1898 }),
1899 );
1900 assertEquals(cancelled.status, "cancelled");
1901
1902 // Cancelling a finished run is a conflict, not a silent no-op.
1903 const again = await api(
1904 ci,
1905 "PATCH",
1906 `/v1/repos/alice/manual/runs/${queued.id}`,
1907 {
1908 body: { status: "cancelled" },
1909 },
1910 );
1911 await drain(again);
1912 assertEquals(again.status, 409);
1913 } finally {
1914 await ci.stop();
1915 }
1916});
1917
1918live("CI is off by default and triggering says so", async (server) => {
1919 await createRepo(server, "quiet");
1920 const health = await json(await api(server, "GET", "/healthz"));
1921 assertEquals(health.ci_enabled, false);
1922
1923 const refused = await api(server, "POST", "/v1/repos/alice/quiet/runs", {
1924 body: {},
1925 });
1926 const problem = await json(refused);
1927 assertEquals(refused.status, 409);
1928 assertStringIncludes(problem.type, "/problems/ci-disabled");
1929});
1930
1931live("an agent sees CI results over MCP", async (_server) => {
1932 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1933 try {
1934 await seedCi(ci, "mcpci", '[run]\nsteps = ["echo built-ok"]\n');
1935 await settle(ci, "mcpci");
1936
1937 const listed = await callTool(ci, "run_list", { repo: "mcpci" });
1938 const runs = listed.result.structuredContent.items;
1939 assertEquals(runs[0].status, "succeeded");
1940
1941 const logs = await callTool(ci, "run_logs", {
1942 repo: "mcpci",
1943 id: runs[0].id,
1944 });
1945 assertStringIncludes(logs.result.structuredContent.logs, "built-ok");
1946
1947 const one = await callTool(ci, "run_get", {
1948 repo: "mcpci",
1949 id: runs[0].id,
1950 });
1951 assertEquals(one.result.structuredContent.exit_code, 0);
1952 } finally {
1953 await ci.stop();
1954 }
1955});
1956
1957live("deleting a repository takes its runs with it", async (_server) => {
1958 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1959 try {
1960 await seedCi(ci, "doomed", '[run]\nsteps = ["true"]\n');
1961 await settle(ci, "doomed");
1962
1963 await drain(await api(ci, "DELETE", "/v1/repos/alice/doomed"));
1964 const runsDir = `${ci.dataDir}/runs/alice/doomed`;
1965 await assertRejects(() => Deno.stat(runsDir));
1966 } finally {
1967 await ci.stop();
1968 }
1969});
1970
1971// ── M7: import ─────────────────────────────────────────────────────────────
1972
1973live(
1974 "import refuses every URL that could reach the local network",
1975 async (server) => {
1976 for (
1977 const url of [
1978 `http://127.0.0.1:${server.port}/alice/x.git`, // this very server
1979 "http://169.254.169.254/latest/meta-data/", // cloud metadata
1980 "http://localhost/a.git", // a name resolving to loopback
1981 "http://10.0.0.1/a.git",
1982 "http://192.168.1.1/a.git",
1983 "file:///etc/passwd",
1984 "ssh://git@github.com/a/b.git",
1985 "git://github.com/a/b.git",
1986 "https://user:token@github.com/a/b.git", // would persist creds in config
1987 "--upload-pack=evil",
1988 ]
1989 ) {
1990 const response = await api(server, "POST", "/v1/repos", {
1991 body: {
1992 name: `imp${Math.floor(Math.random() * 1e9)}`,
1993 import_url: url,
1994 },
1995 });
1996 const problem = await json(response);
1997 assertEquals(response.status, 400, `${url} must be refused`);
1998 assertStringIncludes(problem.type, "/problems/invalid-import-url");
1999 }
2000 },
2001);
2002
2003live(
2004 "a repository imports with its history and is adopted",
2005 async (_server) => {
2006 // Serve a real bare repository over plain HTTP with git's dumb protocol, so the
2007 // import exercises the actual fetch path rather than only the guards. Importing
2008 // from loopback needs the operator opt-in, which is the point of that setting.
2009 const target = await startWith({ ZUKA_IMPORT_ALLOW_PRIVATE: "1" });
2010 const serveDir = await Deno.makeTempDir({ prefix: "zuka-origin-" });
2011 let httpd: Deno.HttpServer | undefined;
2012
2013 try {
2014 // Build a repository with two commits.
2015 const work = `${serveDir}/work`;
2016 await git(["init", "-q", "--initial-branch=main", work], serveDir);
2017 await Deno.writeTextFile(`${work}/README.md`, "imported\n");
2018 await git(["add", "."], work);
2019 await git(["commit", "-m", "first"], work);
2020 await Deno.writeTextFile(`${work}/second.txt`, "two\n");
2021 await git(["add", "."], work);
2022 await git(["commit", "-m", "second"], work);
2023
2024 // Mirror it and generate the files the dumb protocol needs.
2025 const bare = `${serveDir}/origin.git`;
2026 await git(["clone", "--bare", "-q", work, bare], serveDir);
2027 await git(["update-server-info"], bare);
2028 await git(
2029 ["--git-dir", bare, "config", "http.receivepack", "false"],
2030 serveDir,
2031 );
2032
2033 const port = await freePort();
2034 httpd = Deno.serve(
2035 { port, hostname: "127.0.0.1", onListen: () => {} },
2036 async (req) => {
2037 const path = new URL(req.url).pathname.replace(/^\/origin\.git/, "");
2038 try {
2039 return new Response(await Deno.readFile(`${bare}${path}`));
2040 } catch {
2041 return new Response("not found", { status: 404 });
2042 }
2043 },
2044 );
2045
2046 const created = await api(target, "POST", "/v1/repos", {
2047 body: {
2048 name: "copy",
2049 import_url: `http://127.0.0.1:${port}/origin.git`,
2050 },
2051 });
2052 const view = await json(created);
2053 assertEquals(created.status, 201, JSON.stringify(view));
2054 assertEquals(view.name, "copy");
2055
2056 // History arrived.
2057 const log = await json(
2058 await api(target, "GET", "/v1/repos/alice/copy/commits"),
2059 );
2060 assertEquals(log.items.length, 2, "the full history must be imported");
2061 assertEquals(log.items[0].message, "second");
2062
2063 const readme = await drain(
2064 await api(target, "GET", "/v1/repos/alice/copy/raw/README.md"),
2065 );
2066 assertEquals(readme, "imported\n");
2067
2068 // Adopted: an imported repository must be indistinguishable from one we made.
2069 const gitDir = `${target.dataDir}/git/alice/copy.git`;
2070 for (
2071 const [key, expected] of [["gc.auto", "0"], [
2072 "receive.fsckObjects",
2073 "true",
2074 ]]
2075 ) {
2076 const value = await new Deno.Command("git", {
2077 args: ["--git-dir", gitDir, "config", "--get", key],
2078 stdout: "piped",
2079 stderr: "null",
2080 }).output();
2081 assertEquals(
2082 new TextDecoder().decode(value.stdout).trim(),
2083 expected,
2084 `an imported repository must carry ${key}`,
2085 );
2086 }
2087 const hook = await Deno.lstat(`${gitDir}/hooks/update`);
2088 assert(hook.isSymlink, "an imported repository must get our hooks");
2089
2090 // And it serves: clone the copy back out.
2091 const cloned = await git(
2092 ["clone", cloneUrl(target, "alice", "copy"), "roundtrip"],
2093 target.workDir,
2094 );
2095 assertEquals(cloned.code, 0, cloned.stderr);
2096 assertEquals(
2097 await Deno.readTextFile(`${target.workDir}/roundtrip/second.txt`),
2098 "two\n",
2099 );
2100 } finally {
2101 await httpd?.shutdown();
2102 await target.stop();
2103 await Deno.remove(serveDir, { recursive: true }).catch(() => {});
2104 }
2105 },
2106);
2107
2108live("import can be disabled entirely", async (_server) => {
2109 const off = await startWith({ ZUKA_IMPORT_ENABLED: "0" });
2110 try {
2111 const response = await api(off, "POST", "/v1/repos", {
2112 body: {
2113 name: "nope",
2114 import_url: "https://github.com/rust-lang/rust.git",
2115 },
2116 });
2117 const problem = await json(response);
2118 assertEquals(response.status, 409);
2119 assertStringIncludes(problem.type, "/problems/import-disabled");
2120 } finally {
2121 await off.stop();
2122 }
2123});