Skip to content
File

Blob: test/pat-management.worker.test.ts

typescript404 lines
1import { applyD1Migrations } from "cloudflare:test";
2import { env, exports as workerExports } from "cloudflare:workers";
3import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
4 
5import { createDb } from "@/worker/db/d1/client";
6import {
7 findNamespaceBySlug,
8 findPatByPrefix,
9 insertRepositoryIfNew,
10 listPatsForUser,
11} from "@/worker/db/d1/dal";
12import { __test as oidcTest } from "@/worker/auth/oidc";
13 
14import { fakeProvider } from "./util/oidcFake";
15import { readAppD1Migrations } from "./util/d1Migrations";
16import {
17 extractSessionToken,
18 oidcTransactionCookieHeader,
19 sessionCookieHeader,
20} from "./util/authCookies";
21 
22beforeAll(async () => {
23 await applyD1Migrations(env.DB, readAppD1Migrations());
24});
25 
26beforeEach(() => {
27 oidcTest.setProviderForTesting(
28 {
29 issuer: env.TESSERA_OIDC_ISSUER,
30 clientId: env.TESSERA_OIDC_CLIENT_ID,
31 clientSecret: env.TESSERA_OIDC_CLIENT_SECRET,
32 },
33 fakeProvider({
34 authorizationEndpoint: "https://auth.example.com/authorize",
35 tokenEndpoint: "https://auth.example.com/token",
36 jwksUri: "https://auth.example.com/.well-known/jwks.json",
37 })
38 );
39});
40 
41afterEach(() => {
42 oidcTest.clearProviderCache();
43 oidcTest.setAuthorizationCodeGrantImpl(null);
44});
45 
46async function signInAndGetCookie(sub: string, preferredUsername: string): Promise<string> {
47 const state = `state-${sub}`;
48 const cookie = await oidcTransactionCookieHeader(env.TESSERA_OIDC_CLIENT_SECRET, {
49 state,
50 nonce: "n",
51 codeVerifier: "v",
52 redirectUri: "https://example.com/auth/callback",
53 createdAt: Date.now(),
54 });
55 oidcTest.setAuthorizationCodeGrantImpl(async () => {
56 return {
57 access_token: "fake",
58 token_type: "Bearer",
59 claims: () => ({ sub, preferred_username: preferredUsername }),
60 } as unknown as Awaited<ReturnType<typeof import("openid-client").authorizationCodeGrant>>;
61 });
62 const url = new URL("https://example.com/auth/callback");
63 url.searchParams.set("code", "x");
64 url.searchParams.set("state", state);
65 const res = await workerExports.default.fetch(url.toString(), {
66 redirect: "manual",
67 headers: { Cookie: cookie },
68 });
69 expect(res.status).toBe(302);
70 const token = extractSessionToken(res.headers.get("set-cookie"));
71 expect(token).toBeTruthy();
72 return token!;
73}
74 
75describe("PAT management endpoints", () => {
76 it("creates, lists, and revokes a token; cross-user revoke fails", async () => {
77 const aliceCookie = await signInAndGetCookie("sub-alice", "pat-alice");
78 const bobCookie = await signInAndGetCookie("sub-bob", "pat-bob");
79 
80 // Create token for Alice scoped to her namespace.
81 const create = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
82 method: "POST",
83 headers: {
84 "Content-Type": "application/json",
85 Origin: "https://example.com",
86 Cookie: sessionCookieHeader(aliceCookie),
87 },
88 body: JSON.stringify({
89 scope: "namespace",
90 name: "ci",
91 namespaceSlug: "pat-alice",
92 level: "push",
93 }),
94 });
95 expect(create.status).toBe(200);
96 const created = (await create.json()) as { id: string; plaintext: string; prefix: string };
97 expect(created.plaintext.startsWith(`${created.prefix}_`)).toBe(true);
98 
99 // Plaintext is not retrievable on subsequent list.
100 const list = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
101 headers: { Cookie: sessionCookieHeader(aliceCookie) },
102 });
103 expect(list.status).toBe(200);
104 const listed = (await list.json()) as {
105 tokens: Array<{
106 id: string;
107 prefix: string;
108 namespaceGrants: Array<{ namespaceSlug: string; level: "pull" | "push" }>;
109 repoGrants: unknown[];
110 }>;
111 };
112 expect(listed.tokens.length).toBe(1);
113 expect(listed.tokens[0]?.id).toBe(created.id);
114 // Permissions and namespace are surfaced for audit.
115 expect(listed.tokens[0]?.namespaceGrants).toEqual([
116 { namespaceSlug: "pat-alice", level: "push" },
117 ]);
118 expect(listed.tokens[0]?.repoGrants).toEqual([]);
119 // No `plaintext` field is leaked.
120 for (const token of listed.tokens) {
121 expect(Object.keys(token)).not.toContain("plaintext");
122 }
123 // DB has the matching prefix and a non-empty hash.
124 const db = createDb(env.DB);
125 const stored = await findPatByPrefix(db, created.prefix);
126 expect(stored?.hash).toMatch(/^[0-9a-f]{64}$/);
127 
128 // Bob cannot revoke Alice's token.
129 const cross = await workerExports.default.fetch(
130 `https://example.com/auth/api/tokens/${created.id}`,
131 {
132 method: "DELETE",
133 headers: { Cookie: sessionCookieHeader(bobCookie), Origin: "https://example.com" },
134 }
135 );
136 expect(cross.status).toBe(403);
137 
138 // Alice can.
139 const revoke = await workerExports.default.fetch(
140 `https://example.com/auth/api/tokens/${created.id}`,
141 {
142 method: "DELETE",
143 headers: { Cookie: sessionCookieHeader(aliceCookie), Origin: "https://example.com" },
144 }
145 );
146 expect(revoke.status).toBe(200);
147 
148 // Re-revoke is idempotent (200 ok with already-revoked treated as ok).
149 const revoke2 = await workerExports.default.fetch(
150 `https://example.com/auth/api/tokens/${created.id}`,
151 {
152 method: "DELETE",
153 headers: { Cookie: sessionCookieHeader(aliceCookie), Origin: "https://example.com" },
154 }
155 );
156 expect(revoke2.status).toBe(200);
157 
158 // The PAT row still exists with revokedAt set.
159 const tokens = await listPatsForUser(db, stored!.userId);
160 expect(tokens.find((row) => row.id === created.id)?.revokedAt).not.toBeNull();
161 });
162 
163 it("rejects POST without same-origin Origin header", async () => {
164 const cookie = await signInAndGetCookie("sub-cross", "pat-cross");
165 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
166 method: "POST",
167 headers: {
168 "Content-Type": "application/json",
169 Origin: "https://attacker.example",
170 Cookie: sessionCookieHeader(cookie),
171 },
172 body: JSON.stringify({
173 scope: "namespace",
174 name: "ci",
175 namespaceSlug: "pat-cross",
176 level: "pull",
177 }),
178 });
179 expect(res.status).toBe(403);
180 });
181 
182 it("rejects POST when the namespace is not the viewer's", async () => {
183 const cookie = await signInAndGetCookie("sub-other", "pat-other");
184 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
185 method: "POST",
186 headers: {
187 "Content-Type": "application/json",
188 Origin: "https://example.com",
189 Cookie: sessionCookieHeader(cookie),
190 },
191 body: JSON.stringify({
192 scope: "namespace",
193 name: "ci",
194 namespaceSlug: "does-not-exist",
195 level: "pull",
196 }),
197 });
198 expect(res.status).toBe(404);
199 });
200 
201 it("rejects POST when scope is missing from the body", async () => {
202 const cookie = await signInAndGetCookie("sub-no-scope", "pat-no-scope");
203 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
204 method: "POST",
205 headers: {
206 "Content-Type": "application/json",
207 Origin: "https://example.com",
208 Cookie: sessionCookieHeader(cookie),
209 },
210 // No `scope` field — must be a 400, not coerced to namespace.
211 body: JSON.stringify({
212 name: "ci",
213 namespaceSlug: "pat-no-scope",
214 level: "pull",
215 }),
216 });
217 expect(res.status).toBe(400);
218 const body = (await res.json()) as { error?: string };
219 expect(body.error).toMatch(/scope/i);
220 });
221 
222 it("rejects POST when level is missing from the body", async () => {
223 const cookie = await signInAndGetCookie("sub-no-level", "pat-no-level");
224 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
225 method: "POST",
226 headers: {
227 "Content-Type": "application/json",
228 Origin: "https://example.com",
229 Cookie: sessionCookieHeader(cookie),
230 },
231 body: JSON.stringify({
232 scope: "namespace",
233 name: "ci",
234 namespaceSlug: "pat-no-level",
235 }),
236 });
237 expect(res.status).toBe(400);
238 const body = (await res.json()) as { error?: string };
239 expect(body.error).toMatch(/level/i);
240 });
241 
242 it("rejects POST when level is not 'pull' or 'push'", async () => {
243 const cookie = await signInAndGetCookie("sub-bad-level", "pat-bad-level");
244 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
245 method: "POST",
246 headers: {
247 "Content-Type": "application/json",
248 Origin: "https://example.com",
249 Cookie: sessionCookieHeader(cookie),
250 },
251 body: JSON.stringify({
252 scope: "namespace",
253 name: "ci",
254 namespaceSlug: "pat-bad-level",
255 level: "admin",
256 }),
257 });
258 expect(res.status).toBe(400);
259 const body = (await res.json()) as { error?: string };
260 expect(body.error).toMatch(/level/i);
261 });
262 
263 it("creates a repo-scoped token; list shows repoGrants populated", async () => {
264 const cookie = await signInAndGetCookie("sub-repo-pat", "pat-repo");
265 // Seed a repository in the viewer's namespace so the create path can
266 // resolve `(namespaceSlug, repoSlug)` to a repo row.
267 const db = createDb(env.DB);
268 const namespace = await findNamespaceBySlug(db, "pat-repo");
269 expect(namespace).toBeDefined();
270 await insertRepositoryIfNew(db, {
271 id: "repo-pat-scope",
272 namespaceId: namespace!.id,
273 createdBy: namespace!.createdBy,
274 slug: "site",
275 doName: "pat-repo/site",
276 visibility: "public",
277 createdAt: Date.now(),
278 updatedAt: Date.now(),
279 });
280 
281 const create = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
282 method: "POST",
283 headers: {
284 "Content-Type": "application/json",
285 Origin: "https://example.com",
286 Cookie: sessionCookieHeader(cookie),
287 },
288 body: JSON.stringify({
289 scope: "repo",
290 name: "ci-repo",
291 namespaceSlug: "pat-repo",
292 repoSlug: "site",
293 level: "push",
294 }),
295 });
296 expect(create.status).toBe(200);
297 const created = (await create.json()) as { id: string; plaintext: string; prefix: string };
298 
299 const list = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
300 headers: { Cookie: sessionCookieHeader(cookie) },
301 });
302 expect(list.status).toBe(200);
303 const listed = (await list.json()) as {
304 tokens: Array<{
305 id: string;
306 namespaceGrants: Array<{ namespaceSlug: string; level: "pull" | "push" }>;
307 repoGrants: Array<{
308 namespaceSlug: string;
309 repoSlug: string;
310 level: "pull" | "push";
311 }>;
312 }>;
313 };
314 const token = listed.tokens.find((row) => row.id === created.id);
315 expect(token).toBeDefined();
316 // Namespace grants stay empty; the repo grant carries the level.
317 expect(token!.namespaceGrants).toEqual([]);
318 expect(token!.repoGrants).toEqual([
319 { namespaceSlug: "pat-repo", repoSlug: "site", level: "push" },
320 ]);
321 });
322 
323 it("rejects POST scope:'repo' when the repo does not exist", async () => {
324 const cookie = await signInAndGetCookie("sub-repo-missing", "pat-rmissing");
325 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
326 method: "POST",
327 headers: {
328 "Content-Type": "application/json",
329 Origin: "https://example.com",
330 Cookie: sessionCookieHeader(cookie),
331 },
332 body: JSON.stringify({
333 scope: "repo",
334 name: "ci",
335 namespaceSlug: "pat-rmissing",
336 repoSlug: "ghost",
337 level: "pull",
338 }),
339 });
340 expect(res.status).toBe(404);
341 });
342 
343 it("rejects POST scope:'repo' for a namespace the viewer is not a member of", async () => {
344 // Sign in as user A so the namespace + membership exist, then seed a
345 // repo under A's namespace. The owner cookie itself isn't used; we only
346 // need its side-effects (user, namespace, membership rows).
347 await signInAndGetCookie("sub-repo-owner", "pat-rowner");
348 const db = createDb(env.DB);
349 const ownerNamespace = await findNamespaceBySlug(db, "pat-rowner");
350 expect(ownerNamespace).toBeDefined();
351 await insertRepositoryIfNew(db, {
352 id: "repo-pat-cross",
353 namespaceId: ownerNamespace!.id,
354 createdBy: ownerNamespace!.createdBy,
355 slug: "private-site",
356 doName: "pat-rowner/private-site",
357 visibility: "public",
358 createdAt: Date.now(),
359 updatedAt: Date.now(),
360 });
361 // Then sign in as user B and try to mint a PAT against A's repo.
362 const intruderCookie = await signInAndGetCookie("sub-repo-intruder", "pat-rintruder");
363 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
364 method: "POST",
365 headers: {
366 "Content-Type": "application/json",
367 Origin: "https://example.com",
368 Cookie: sessionCookieHeader(intruderCookie),
369 },
370 body: JSON.stringify({
371 scope: "repo",
372 name: "ci",
373 namespaceSlug: "pat-rowner",
374 repoSlug: "private-site",
375 level: "pull",
376 }),
377 });
378 expect(res.status).toBe(403);
379 });
380 
381 it("rejects POST scope:'repo' with a malformed repoSlug", async () => {
382 const cookie = await signInAndGetCookie("sub-repo-badslug", "pat-rbadslug");
383 const res = await workerExports.default.fetch("https://example.com/auth/api/tokens", {
384 method: "POST",
385 headers: {
386 "Content-Type": "application/json",
387 Origin: "https://example.com",
388 Cookie: sessionCookieHeader(cookie),
389 },
390 body: JSON.stringify({
391 scope: "repo",
392 name: "ci",
393 namespaceSlug: "pat-rbadslug",
394 // Uppercase + dot fails the strict slug policy.
395 repoSlug: "Bad.Slug",
396 level: "pull",
397 }),
398 });
399 expect(res.status).toBe(400);
400 const body = (await res.json()) as { error?: string };
401 expect(body.error).toMatch(/repo slug/i);
402 });
403});