Skip to content
File

Blob: test/pack-first-read-path.routes.worker.test.ts

typescript278 lines
1import { describe, expect, it } from "vitest";
2import { env, exports as workerExports } from "cloudflare:workers";
3import { asTypedStorage, type RepoStateSchema } from "@/worker/do/repo/repoState";
4import { computeNeededFast } from "@/worker/git/operations/fetch/neededFast";
5import { packRefsKey } from "@/worker/keys";
6import {
7 deleteLooseObjectCopies,
8 runDOWithRetry,
9 toRequestBody,
10 uniqueRepoId,
11} from "./util/test-helpers";
12import { setupRepoForTests } from "./util/repoSeed";
13import { buildFetchBody, decodePktTextLines } from "./util/fetch-protocol";
14import { seedPackFirstRepo } from "./util/pack-first";
15 
16describe("pack-first read path routes", () => {
17 it("serves fetch and UI routes after deleting all loose object copies", async () => {
18 const owner = "o";
19 const repo = uniqueRepoId("pack-read-path");
20 await setupRepoForTests(env, owner, repo);
21 const repoId = `${owner}/${repo}`;
22 const seeded = await seedPackFirstRepo(repoId);
23 
24 await deleteLooseObjectCopies(env, seeded.getStub, seeded.objectOids);
25 
26 const needed = await computeNeededFast(
27 env,
28 repoId,
29 [seeded.nextCommit.oid],
30 [seeded.baseCommit.oid]
31 );
32 expect(new Set(needed)).toEqual(
33 new Set([seeded.nextCommit.oid, seeded.nextTree.oid, seeded.nextBlob.oid])
34 );
35 
36 const ackResponse = await workerExports.default.fetch(
37 `https://example.com/${owner}/${repo}/git-upload-pack`,
38 {
39 method: "POST",
40 headers: {
41 "Content-Type": "application/x-git-upload-pack-request",
42 "Git-Protocol": "version=2",
43 },
44 body: toRequestBody(
45 buildFetchBody({
46 wants: [seeded.nextCommit.oid],
47 haves: [seeded.baseCommit.oid],
48 })
49 ),
50 }
51 );
52 expect(ackResponse.status).toBe(200);
53 const ackLines = decodePktTextLines(new Uint8Array(await ackResponse.arrayBuffer()));
54 expect(ackLines).toContain(`ACK ${seeded.baseCommit.oid} ready`);
55 
56 const fetchResponse = await workerExports.default.fetch(
57 `https://example.com/${owner}/${repo}/git-upload-pack`,
58 {
59 method: "POST",
60 headers: {
61 "Content-Type": "application/x-git-upload-pack-request",
62 "Git-Protocol": "version=2",
63 },
64 body: toRequestBody(
65 buildFetchBody({
66 wants: [seeded.nextCommit.oid],
67 haves: [seeded.baseCommit.oid],
68 done: true,
69 })
70 ),
71 }
72 );
73 expect(fetchResponse.status).toBe(200);
74 const fetchBytes = new Uint8Array(await fetchResponse.arrayBuffer());
75 expect(new TextDecoder().decode(fetchBytes.subarray(4, 13))).toBe("packfile\n");
76 
77 const treeResponse = await workerExports.default.fetch(
78 `https://example.com/${owner}/${repo}/tree?ref=main`
79 );
80 expect(treeResponse.status).toBe(200);
81 expect(await treeResponse.text()).toContain("README.md");
82 
83 const blobResponse = await workerExports.default.fetch(
84 `https://example.com/${owner}/${repo}/blob?ref=main&path=${encodeURIComponent("README.md")}`
85 );
86 expect(blobResponse.status).toBe(200);
87 expect(await blobResponse.text()).toContain("version two");
88 
89 const rawResponse = await workerExports.default.fetch(
90 `https://example.com/${owner}/${repo}/raw?oid=${seeded.nextBlob.oid}&name=README.md`
91 );
92 expect(rawResponse.status).toBe(200);
93 expect(await rawResponse.text()).toBe("version two\n");
94 
95 const commitResponse = await workerExports.default.fetch(
96 `https://example.com/${owner}/${repo}/commit/${seeded.nextCommit.oid}`
97 );
98 expect(commitResponse.status).toBe(200);
99 const commitHtml = await commitResponse.text();
100 expect(commitHtml).toContain("second commit");
101 expect(commitHtml).toContain("README.md");
102 
103 const diffResponse = await workerExports.default.fetch(
104 `https://example.com/${owner}/${repo}/commit/${seeded.nextCommit.oid}/diff?path=${encodeURIComponent("README.md")}`
105 );
106 expect(diffResponse.status).toBe(200);
107 const diffJson = (await diffResponse.json()) as { patch?: string; skipReason?: string };
108 expect(diffJson.skipReason).toBeUndefined();
109 expect(diffJson.patch).toContain("-version one");
110 expect(diffJson.patch).toContain("+version two");
111 });
112 
113 it("keeps admin debug endpoints on the shared DO contract after loose copies are deleted", async () => {
114 const owner = "o";
115 const repo = uniqueRepoId("pack-debug-contract");
116 const seededRepo = await setupRepoForTests(env, owner, repo);
117 const repoId = `${owner}/${repo}`;
118 const seeded = await seedPackFirstRepo(repoId);
119 await deleteLooseObjectCopies(env, seeded.getStub, seeded.objectOids);
120 
121 const commitResponse = await workerExports.default.fetch(
122 `https://example.com/${owner}/${repo}/admin/debug-commit/${seeded.nextCommit.oid}`,
123 { headers: { Cookie: seededRepo.cookieHeader } }
124 );
125 expect(commitResponse.status).toBe(200);
126 const commitJson = (await commitResponse.json()) as {
127 commit?: { oid?: string; tree?: string; parents?: string[] };
128 presence?: {
129 hasLooseCommit?: boolean;
130 hasLooseTree?: boolean;
131 hasR2LooseTree?: boolean;
132 };
133 membership?: Record<string, { hasCommit?: boolean; hasTree?: boolean }>;
134 inPacks?: unknown;
135 };
136 expect(commitJson.commit?.oid).toBe(seeded.nextCommit.oid);
137 expect(commitJson.commit?.tree).toBe(seeded.nextTree.oid);
138 expect(commitJson.presence?.hasLooseCommit).toBe(false);
139 expect(commitJson.presence?.hasLooseTree).toBe(false);
140 expect(commitJson.presence?.hasR2LooseTree).toBe(false);
141 expect(commitJson.membership?.[seeded.packKeys[0]]).toEqual({
142 hasCommit: true,
143 hasTree: true,
144 });
145 expect(commitJson.inPacks).toBeUndefined();
146 
147 const oidResponse = await workerExports.default.fetch(
148 `https://example.com/${owner}/${repo}/admin/debug-oid/${seeded.nextBlob.oid}`,
149 { headers: { Cookie: seededRepo.cookieHeader } }
150 );
151 expect(oidResponse.status).toBe(200);
152 const oidJson = (await oidResponse.json()) as {
153 oid?: string;
154 presence?: { hasLoose?: boolean; hasR2Loose?: boolean; hasPacked?: boolean };
155 inPacks?: string[];
156 };
157 expect(oidJson.oid).toBe(seeded.nextBlob.oid);
158 expect(oidJson.presence?.hasLoose).toBe(false);
159 expect(oidJson.presence?.hasR2Loose).toBe(false);
160 expect(oidJson.presence?.hasPacked).toBeUndefined();
161 expect(oidJson.inPacks).toEqual([seeded.packKeys[0]]);
162 });
163 
164 it("renders pack .refs sidecar status on the admin page", async () => {
165 const owner = "o";
166 const repo = uniqueRepoId("pack-admin-refs-sidecar");
167 const seededRepo = await setupRepoForTests(env, owner, repo);
168 const repoId = `${owner}/${repo}`;
169 const seeded = await seedPackFirstRepo(repoId);
170 const packKey = seeded.packKeys[0];
171 if (!packKey) throw new Error("missing seeded pack key");
172 
173 const presentResponse = await workerExports.default.fetch(
174 `https://example.com/${owner}/${repo}/admin`,
175 { headers: { Cookie: seededRepo.cookieHeader } }
176 );
177 expect(presentResponse.status).toBe(200);
178 const presentHtml = await presentResponse.text();
179 expect(presentHtml).toContain("Reference sidecar is present in R2");
180 
181 await env.REPO_BUCKET.delete(packRefsKey(packKey));
182 
183 const missingResponse = await workerExports.default.fetch(
184 `https://example.com/${owner}/${repo}/admin`,
185 { headers: { Cookie: seededRepo.cookieHeader } }
186 );
187 expect(missingResponse.status).toBe(200);
188 const missingHtml = await missingResponse.text();
189 expect(missingHtml).toContain("Reference sidecar is missing from R2");
190 });
191 
192 it("rejects deleting an active pack through the admin route", async () => {
193 const owner = "o";
194 const repo = uniqueRepoId("pack-delete-guard");
195 const seededRepo = await setupRepoForTests(env, owner, repo);
196 const repoId = `${owner}/${repo}`;
197 const seeded = await seedPackFirstRepo(repoId);
198 const activePackName = seeded.packKeys[0]?.split("/").pop();
199 if (!activePackName) throw new Error("missing active pack name");
200 
201 const stateResponse = await workerExports.default.fetch(
202 `https://example.com/${owner}/${repo}/admin/debug-state`,
203 { headers: { Cookie: seededRepo.cookieHeader } }
204 );
205 expect(stateResponse.status).toBe(200);
206 const stateJson = (await stateResponse.json()) as {
207 activePacks?: Array<{ key: string }>;
208 packCatalogVersion?: number;
209 };
210 expect(stateJson.activePacks?.[0]?.key).toBe(seeded.packKeys[0]);
211 expect(typeof stateJson.packCatalogVersion).toBe("number");
212 
213 const deleteResponse = await workerExports.default.fetch(
214 `https://example.com/${owner}/${repo}/admin/pack/${encodeURIComponent(activePackName)}`,
215 {
216 method: "DELETE",
217 headers: { Cookie: seededRepo.cookieHeader, Origin: "https://example.com" },
218 }
219 );
220 expect(deleteResponse.status).toBe(409);
221 const deleteJson = (await deleteResponse.json()) as { error?: string; rejected?: string };
222 expect(deleteJson.rejected).toBe("active-pack");
223 expect(deleteJson.error).toContain("Active packs");
224 });
225 
226 it("rejects deleting a pack that is not superseded", async () => {
227 const owner = "o";
228 const repo = uniqueRepoId("pack-delete-non-superseded");
229 const seededRepo = await setupRepoForTests(env, owner, repo);
230 const repoId = `${owner}/${repo}`;
231 await seedPackFirstRepo(repoId);
232 
233 const deleteResponse = await workerExports.default.fetch(
234 `https://example.com/${owner}/${repo}/admin/pack/${encodeURIComponent("pack-missing.pack")}`,
235 {
236 method: "DELETE",
237 headers: { Cookie: seededRepo.cookieHeader, Origin: "https://example.com" },
238 }
239 );
240 expect(deleteResponse.status).toBe(409);
241 const deleteJson = (await deleteResponse.json()) as {
242 error?: string;
243 rejected?: string;
244 packState?: string;
245 };
246 expect(deleteJson.rejected).toBe("non-superseded-pack");
247 expect(deleteJson.packState).toBe("unknown");
248 expect(deleteJson.error).toContain("Only superseded packs");
249 });
250 
251 it("renders receiving state on the admin page when a receive lease is active", async () => {
252 const owner = "o";
253 const repo = uniqueRepoId("pack-admin-receiving");
254 const seededRepo = await setupRepoForTests(env, owner, repo);
255 const repoId = `${owner}/${repo}`;
256 const seeded = await seedPackFirstRepo(repoId);
257 
258 await runDOWithRetry(seeded.getStub, async (_instance, state) => {
259 const store = asTypedStorage<RepoStateSchema>(state.storage);
260 const now = Date.now();
261 await store.put("receiveLease", {
262 token: "test-receive-lease",
263 createdAt: now,
264 expiresAt: now + 60_000,
265 });
266 });
267 
268 const response = await workerExports.default.fetch(
269 `https://example.com/${owner}/${repo}/admin`,
270 { headers: { Cookie: seededRepo.cookieHeader } }
271 );
272 expect(response.status).toBe(200);
273 const html = await response.text();
274 expect(html).toContain("Receiving push...");
275 expect(html).toContain("receive lease is active");
276 });
277});