Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

131 lines · 5.8 KBRaw History
1import { describe, expect, it } from "vitest";
2import { isStale, mirrorKey, parseActionRef, refreshMirror, REFRESH_MS, type MirrorBucket } from "../../src/lib/action-mirror.js";
3
4/** An in-memory bucket that remembers when each object was stored. */
5function memoryBucket(now: () => number) {
6 const objects = new Map<string, { data: Uint8Array; uploaded: Date }>();
7 const bytes = async (v: ReadableStream | ArrayBuffer | ArrayBufferView) => new Uint8Array(await new Response(v as BodyInit).arrayBuffer());
8 const bucket: MirrorBucket = {
9 async get(key) {
10 const o = objects.get(key);
11 return o ? { body: new Response(o.data).body!, size: o.data.length } : null;
12 },
13 async head(key) {
14 const o = objects.get(key);
15 return o ? { size: o.data.length, etag: "e", uploaded: o.uploaded } : null;
16 },
17 async put(key, value) {
18 const data = await bytes(value);
19 objects.set(key, { data, uploaded: new Date(now()) });
20 return { size: data.length, etag: "e" };
21 },
22 async list() {
23 return { objects: [], truncated: false };
24 },
25 async delete() {},
26 async createMultipartUpload(key) {
27 const parts = new Map<number, Uint8Array>();
28 return {
29 uploadId: "u",
30 async uploadPart(n, v) {
31 parts.set(n, await bytes(v));
32 return { partNumber: n, etag: String(n) };
33 },
34 async complete(list) {
35 const data = new Uint8Array(await new Blob(list.map((p) => parts.get(p.partNumber)! as BlobPart)).arrayBuffer());
36 objects.set(key, { data, uploaded: new Date(now()) });
37 },
38 async abort() {},
39 };
40 },
41 resumeMultipartUpload() {
42 throw new Error("unused");
43 },
44 };
45 return { bucket, objects };
46}
47
48const T0 = Date.parse("2026-10-26T12:00:00Z");
49
50describe("action mirror", () => {
51 it("accepts GitHub action names and refs, and nothing that could escape the key or the URL", () => {
52 expect(parseActionRef({ owner: "actions", repo: "setup-java", ref: "v4" })).toEqual({ owner: "actions", repo: "setup-java", ref: "v4" });
53 expect(parseActionRef({ owner: "a", repo: "b", ref: "releases/v1.2" })).not.toBeNull();
54 for (const bad of [
55 { owner: "../x", repo: "b", ref: "v1" },
56 { owner: "a", repo: "..", ref: "v1" },
57 { owner: "a/b", repo: "c", ref: "v1" },
58 { owner: "a", repo: "b", ref: "../../etc" },
59 { owner: "a", repo: "b", ref: "-x" },
60 { owner: "a", repo: "b", ref: "v1 v2" },
61 { owner: "a", repo: "b", ref: "" },
62 { owner: "a", repo: "b" },
63 { owner: 1, repo: "b", ref: "v1" },
64 ]) {
65 expect(parseActionRef(bad), JSON.stringify(bad)).toBeNull();
66 }
67 });
68
69 it("keys copies by lower-case owner and repository, and keeps refs apart", () => {
70 expect(mirrorKey({ owner: "Actions", repo: "Checkout", ref: "v4" })).toBe("action-mirror/actions/checkout/v4.tar.gz");
71 expect(mirrorKey({ owner: "a", repo: "b", ref: "releases/v1" })).toBe("action-mirror/a/b/releases%2Fv1.tar.gz");
72 const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
73 expect(mirrorKey({ owner: "a", repo: "b", ref: sha })).toBe(`action-mirror/a/b/${sha.toLowerCase()}.tar.gz`);
74 });
75
76 it("refreshes a tag after a day and a commit after a week", () => {
77 const tag = { owner: "a", repo: "b", ref: "v1" };
78 const sha = { owner: "a", repo: "b", ref: "0123456789abcdef0123456789abcdef01234567" };
79 expect(isStale(tag, undefined, T0)).toBe(true);
80 expect(isStale(tag, new Date(T0 - REFRESH_MS.ref + 1000), T0)).toBe(false);
81 expect(isStale(tag, new Date(T0 - REFRESH_MS.ref), T0)).toBe(true);
82 expect(isStale(sha, new Date(T0 - REFRESH_MS.ref), T0)).toBe(false);
83 expect(isStale(sha, new Date(T0 - REFRESH_MS.sha), T0)).toBe(true);
84 });
85
86 it("fetches from GitHub only when its copy is missing or stale, and keeps the old copy when GitHub fails", async () => {
87 let now = T0;
88 const { bucket, objects } = memoryBucket(() => now);
89 const calls: string[] = [];
90 let answer: () => Response = () => new Response("tarball-1");
91 const fetchStub = (async (url: string) => {
92 calls.push(url);
93 return answer();
94 }) as unknown as typeof fetch;
95 const a = { owner: "acme", repo: "tools", ref: "v1" };
96 const o = { fetch: fetchStub, codeload: "https://codeload.test" };
97
98 expect(await refreshMirror(bucket, a, { ...o, now })).toBe("stored");
99 expect(calls).toEqual(["https://codeload.test/acme/tools/tar.gz/v1"]);
100 expect(new TextDecoder().decode(objects.get(mirrorKey(a))!.data)).toBe("tarball-1");
101
102 now += 3600_000;
103 expect(await refreshMirror(bucket, a, { ...o, now })).toBe("fresh");
104 expect(calls).toHaveLength(1);
105
106 now += REFRESH_MS.ref;
107 answer = () => new Response("down", { status: 503 });
108 expect(await refreshMirror(bucket, a, { ...o, now })).toBe("failed");
109 expect(new TextDecoder().decode(objects.get(mirrorKey(a))!.data)).toBe("tarball-1");
110
111 answer = () => new Response("tarball-2");
112 expect(await refreshMirror(bucket, a, { ...o, now })).toBe("stored");
113 expect(new TextDecoder().decode(objects.get(mirrorKey(a))!.data)).toBe("tarball-2");
114
115 answer = () => new Response("nope", { status: 404 });
116 expect(await refreshMirror(bucket, { ...a, ref: "v9" }, { ...o, now })).toBe("not-found");
117 expect(objects.has(mirrorKey({ ...a, ref: "v9" }))).toBe(false);
118
119 answer = () => {
120 throw new TypeError("network down");
121 };
122 expect(await refreshMirror(bucket, { ...a, ref: "v8" }, { ...o, now })).toBe("failed");
123 });
124
125 it("stores nothing for an archive over the size limit", async () => {
126 const { bucket, objects } = memoryBucket(() => T0);
127 const big = (() => new Response("x", { headers: { "content-length": String(1024 * 1024 * 1024) } })) as unknown as typeof fetch;
128 expect(await refreshMirror(bucket, { owner: "a", repo: "huge", ref: "v1" }, { fetch: big })).toBe("too-large");
129 expect(objects.size).toBe(0);
130 });
131});