Skip to content
File

Blob: test/git-acl.worker.test.ts

typescript317 lines
1import { beforeAll, describe, expect, it } from "vitest";
2import { env, exports as workerExports } from "cloudflare:workers";
3 
4import { newPrefixedId } from "@/worker/common";
5import { createDb } from "@/worker/db/d1/client";
6import { generatePatPlaintext, hashPatPlaintext } from "@/worker/auth/pat";
7import { insertPatWithGrants } from "@/worker/db/d1/dal";
8 
9import { ensureD1Migrations } from "./util/d1Setup";
10import { seedRepo, type SeededRepo } from "./util/repoSeed";
11 
12beforeAll(async () => {
13 await ensureD1Migrations(env);
14});
15 
16function basicAuth(user: string, pass: string): string {
17 return `Basic ${btoa(`${user}:${pass}`)}`;
18}
19 
20async function seedPat(args: {
21 userId: string;
22 level: "pull" | "push";
23 scope: { kind: "repo"; repoId: string } | { kind: "namespace"; namespaceId: string };
24 revokedAt?: number;
25 expiresAt?: number;
26}): Promise<string> {
27 const db = createDb(env.DB);
28 const generated = generatePatPlaintext();
29 const hash = await hashPatPlaintext(generated.plaintext);
30 const patId = newPrefixedId("pat");
31 await insertPatWithGrants(db, {
32 pat: {
33 id: patId,
34 userId: args.userId,
35 name: "ci",
36 prefix: generated.publicPrefix,
37 hash,
38 createdAt: Date.now(),
39 expiresAt: args.expiresAt ?? null,
40 revokedAt: args.revokedAt ?? null,
41 lastUsedAt: null,
42 },
43 namespaceGrants:
44 args.scope.kind === "namespace"
45 ? [{ patId, namespaceId: args.scope.namespaceId, level: args.level }]
46 : [],
47 repoGrants:
48 args.scope.kind === "repo" ? [{ patId, repoId: args.scope.repoId, level: args.level }] : [],
49 });
50 return generated.plaintext;
51}
52 
53function infoRefs(seed: SeededRepo, service: "git-upload-pack" | "git-receive-pack"): string {
54 return `https://example.com/${seed.namespaceSlug}/${seed.repoSlug}/info/refs?service=${service}`;
55}
56 
57function uploadPackUrl(seed: SeededRepo): string {
58 return `https://example.com/${seed.namespaceSlug}/${seed.repoSlug}/git-upload-pack`;
59}
60 
61function receivePackUrl(seed: SeededRepo): string {
62 return `https://example.com/${seed.namespaceSlug}/${seed.repoSlug}/git-receive-pack`;
63}
64 
65const FETCH_BODY = new TextEncoder().encode("");
66 
67describe("Git ACL: read paths", () => {
68 it("public + anonymous + info-refs upload-pack -> 200", async () => {
69 const seed = await seedRepo(env, {
70 namespaceSlug: `acl-pub-${Math.random().toString(36).slice(2, 8)}`,
71 repoSlug: "site",
72 visibility: "public",
73 });
74 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"));
75 expect(res.status).toBe(200);
76 expect(res.headers.get("Content-Type")).toContain("git-upload-pack-advertisement");
77 });
78 
79 it("public + anonymous + missing route cache -> 404", async () => {
80 const seed = await seedRepo(env, {
81 namespaceSlug: `acl-pub-miss-${Math.random().toString(36).slice(2, 8)}`,
82 repoSlug: "site",
83 visibility: "public",
84 skipRouteCache: true,
85 });
86 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"));
87 expect(res.status).toBe(404);
88 });
89 
90 it("public + valid PAT + missing route cache -> 200", async () => {
91 const seed = await seedRepo(env, {
92 namespaceSlug: `acl-pat-miss-${Math.random().toString(36).slice(2, 8)}`,
93 repoSlug: "site",
94 visibility: "public",
95 skipRouteCache: true,
96 });
97 const plaintext = await seedPat({
98 userId: seed.userId,
99 level: "pull",
100 scope: { kind: "repo", repoId: seed.repositoryId },
101 });
102 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), {
103 headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) },
104 });
105 expect(res.status).toBe(200);
106 });
107 
108 it("public + malformed PAT + missing route cache -> 401", async () => {
109 const seed = await seedRepo(env, {
110 namespaceSlug: `acl-badpat-miss-${Math.random().toString(36).slice(2, 8)}`,
111 repoSlug: "site",
112 visibility: "public",
113 skipRouteCache: true,
114 });
115 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), {
116 headers: { Authorization: basicAuth(seed.namespaceSlug, "not-a-pat") },
117 });
118 expect(res.status).toBe(401);
119 });
120 
121 it("private + anonymous + info-refs upload-pack -> 404 (non-disclosure)", async () => {
122 const seed = await seedRepo(env, {
123 namespaceSlug: `acl-priv-${Math.random().toString(36).slice(2, 8)}`,
124 repoSlug: "site",
125 visibility: "private",
126 });
127 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"));
128 expect(res.status).toBe(404);
129 });
130 
131 it("private + valid PAT (pull) + info-refs upload-pack -> 200", async () => {
132 const seed = await seedRepo(env, {
133 namespaceSlug: `acl-pat-pull-${Math.random().toString(36).slice(2, 8)}`,
134 repoSlug: "site",
135 visibility: "private",
136 });
137 const plaintext = await seedPat({
138 userId: seed.userId,
139 level: "pull",
140 scope: { kind: "repo", repoId: seed.repositoryId },
141 });
142 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), {
143 headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) },
144 });
145 expect(res.status).toBe(200);
146 });
147 
148 it("private + valid PAT + missing route cache -> 200", async () => {
149 const seed = await seedRepo(env, {
150 namespaceSlug: `acl-priv-pat-miss-${Math.random().toString(36).slice(2, 8)}`,
151 repoSlug: "site",
152 visibility: "private",
153 skipRouteCache: true,
154 });
155 const plaintext = await seedPat({
156 userId: seed.userId,
157 level: "pull",
158 scope: { kind: "repo", repoId: seed.repositoryId },
159 });
160 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), {
161 headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) },
162 });
163 expect(res.status).toBe(200);
164 });
165 
166 it("private + valid PAT (push) + info-refs upload-pack -> 200", async () => {
167 const seed = await seedRepo(env, {
168 namespaceSlug: `acl-pat-push-r-${Math.random().toString(36).slice(2, 8)}`,
169 repoSlug: "site",
170 visibility: "private",
171 });
172 const plaintext = await seedPat({
173 userId: seed.userId,
174 level: "push",
175 scope: { kind: "repo", repoId: seed.repositoryId },
176 });
177 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), {
178 headers: { Authorization: basicAuth(seed.namespaceSlug, plaintext) },
179 });
180 expect(res.status).toBe(200);
181 });
182 
183 it("private + Basic username mismatch -> 401", async () => {
184 const seed = await seedRepo(env, {
185 namespaceSlug: `acl-mismatch-${Math.random().toString(36).slice(2, 8)}`,
186 repoSlug: "site",
187 visibility: "private",
188 });
189 const plaintext = await seedPat({
190 userId: seed.userId,
191 level: "pull",
192 scope: { kind: "repo", repoId: seed.repositoryId },
193 });
194 const res = await workerExports.default.fetch(infoRefs(seed, "git-upload-pack"), {
195 headers: { Authorization: basicAuth("not-the-namespace", plaintext) },
196 });
197 expect(res.status).toBe(401);
198 });
199 
200 it("private + git-upload-pack POST without creds -> 404", async () => {
201 const seed = await seedRepo(env, {
202 namespaceSlug: `acl-priv-post-${Math.random().toString(36).slice(2, 8)}`,
203 repoSlug: "site",
204 visibility: "private",
205 });
206 const res = await workerExports.default.fetch(uploadPackUrl(seed), {
207 method: "POST",
208 headers: {
209 "Content-Type": "application/x-git-upload-pack-request",
210 "Git-Protocol": "version=2",
211 },
212 body: FETCH_BODY,
213 });
214 expect(res.status).toBe(404);
215 });
216});
217 
218describe("Git ACL: push paths", () => {
219 it("private + anonymous + info-refs receive-pack -> 401 challenge", async () => {
220 const seed = await seedRepo(env, {
221 namespaceSlug: `acl-priv-recv-${Math.random().toString(36).slice(2, 8)}`,
222 repoSlug: "site",
223 visibility: "private",
224 });
225 const res = await workerExports.default.fetch(infoRefs(seed, "git-receive-pack"));
226 expect(res.status).toBe(401);
227 expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/);
228 });
229 
230 it("private + anonymous + missing route cache + info-refs receive-pack -> 401 challenge", async () => {
231 const seed = await seedRepo(env, {
232 namespaceSlug: `acl-priv-recv-miss-${Math.random().toString(36).slice(2, 8)}`,
233 repoSlug: "site",
234 visibility: "private",
235 skipRouteCache: true,
236 });
237 const res = await workerExports.default.fetch(infoRefs(seed, "git-receive-pack"));
238 expect(res.status).toBe(401);
239 expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/);
240 });
241 
242 it("public + anonymous + missing route cache + info-refs receive-pack -> 401 challenge", async () => {
243 const seed = await seedRepo(env, {
244 namespaceSlug: `acl-pub-recv-miss-${Math.random().toString(36).slice(2, 8)}`,
245 repoSlug: "site",
246 visibility: "public",
247 skipRouteCache: true,
248 });
249 const res = await workerExports.default.fetch(infoRefs(seed, "git-receive-pack"));
250 expect(res.status).toBe(401);
251 expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/);
252 });
253 
254 it("private + anonymous + missing route cache + receive-pack POST -> 401 challenge", async () => {
255 const seed = await seedRepo(env, {
256 namespaceSlug: `acl-priv-post-miss-${Math.random().toString(36).slice(2, 8)}`,
257 repoSlug: "site",
258 visibility: "private",
259 skipRouteCache: true,
260 });
261 const res = await workerExports.default.fetch(receivePackUrl(seed), {
262 method: "POST",
263 headers: {
264 "Content-Type": "application/x-git-receive-pack-request",
265 },
266 body: new Uint8Array(),
267 });
268 expect(res.status).toBe(401);
269 expect(res.headers.get("WWW-Authenticate")).toMatch(/Basic/);
270 });
271 
272 it("private + PAT pull-only + receive-pack POST -> 403", async () => {
273 const seed = await seedRepo(env, {
274 namespaceSlug: `acl-pull-push-${Math.random().toString(36).slice(2, 8)}`,
275 repoSlug: "site",
276 visibility: "private",
277 });
278 const plaintext = await seedPat({
279 userId: seed.userId,
280 level: "pull",
281 scope: { kind: "repo", repoId: seed.repositoryId },
282 });
283 const res = await workerExports.default.fetch(receivePackUrl(seed), {
284 method: "POST",
285 headers: {
286 "Content-Type": "application/x-git-receive-pack-request",
287 Authorization: basicAuth(seed.namespaceSlug, plaintext),
288 },
289 body: new Uint8Array(),
290 });
291 expect(res.status).toBe(403);
292 });
293 
294 it("private + revoked PAT + receive-pack POST -> 401 challenge", async () => {
295 const seed = await seedRepo(env, {
296 namespaceSlug: `acl-revoked-${Math.random().toString(36).slice(2, 8)}`,
297 repoSlug: "site",
298 visibility: "private",
299 });
300 const plaintext = await seedPat({
301 userId: seed.userId,
302 level: "push",
303 scope: { kind: "repo", repoId: seed.repositoryId },
304 revokedAt: Date.now() - 1,
305 });
306 const res = await workerExports.default.fetch(receivePackUrl(seed), {
307 method: "POST",
308 headers: {
309 "Content-Type": "application/x-git-receive-pack-request",
310 Authorization: basicAuth(seed.namespaceSlug, plaintext),
311 },
312 body: new Uint8Array(),
313 });
314 expect(res.status).toBe(401);
315 });
316});