Skip to content
File

Blob: test/repo-create.worker.test.ts

typescript320 lines
1import { beforeAll, beforeEach, describe, expect, it } from "vitest";
2import { env, exports as workerExports } from "cloudflare:workers";
3 
4import { resolveRepositoryRoute } from "@/worker/repositories/route";
5 
6import { ensureD1Migrations } from "./util/d1Setup";
7import { mintSessionCookie, seedRepo } from "./util/repoSeed";
8import { runQueueMessage } from "./util/queue";
9import { newPrefixedId } from "@/worker/common";
10import { createDb } from "@/worker/db/d1/client";
11import { insertUserIfNew, claimNamespace, insertMembershipIfMissing } from "@/worker/db/d1/dal";
12 
13beforeAll(async () => {
14 await ensureD1Migrations(env);
15});
16 
17type CreateOk = {
18 ok: true;
19 id: string;
20 namespaceSlug: string;
21 slug: string;
22 visibility: "public" | "private";
23 updatedAt: number;
24};
25type CreateFail =
26 | { ok: false; reason: "invalid-slug" }
27 | { ok: false; reason: "invalid-visibility" }
28 | { ok: false; reason: "namespace-not-found" }
29 | { ok: false; reason: "not-member" }
30 | { ok: false; reason: "slug-taken" };
31 
32async function createMember(
33 namespaceSlug: string
34): Promise<{ userId: string; cookieHeader: string; namespaceId: string }> {
35 const db = createDb(env.DB);
36 const userId = newPrefixedId("user");
37 const namespaceId = newPrefixedId("ns");
38 const now = Date.now();
39 await insertUserIfNew(db, { id: userId, tesseraSub: `t-${userId}`, createdAt: now });
40 const claimed = await claimNamespace(db, {
41 id: namespaceId,
42 slug: namespaceSlug,
43 createdBy: userId,
44 createdAt: now,
45 });
46 if (!claimed) {
47 throw new Error(`createMember: namespace ${namespaceSlug} already exists`);
48 }
49 await insertMembershipIfMissing(db, {
50 namespaceId: claimed.id,
51 userId,
52 createdAt: now,
53 });
54 const cookieHeader = await mintSessionCookie(env, userId);
55 return { userId, namespaceId: claimed.id, cookieHeader };
56}
57 
58async function postCreate(
59 cookieHeader: string | null,
60 body: Record<string, unknown>
61): Promise<{ status: number; payload: CreateOk | CreateFail | { error: string } }> {
62 const headers: Record<string, string> = {
63 "Content-Type": "application/json",
64 Origin: "https://example.com",
65 };
66 if (cookieHeader) headers.Cookie = cookieHeader;
67 const res = await workerExports.default.fetch("https://example.com/auth/api/repositories", {
68 method: "POST",
69 headers,
70 body: JSON.stringify(body),
71 });
72 return { status: res.status, payload: (await res.json()) as CreateOk | CreateFail };
73}
74 
75describe("POST /auth/api/repositories", () => {
76 let nsCounter = 0;
77 function uniqueNs(prefix: string): string {
78 nsCounter += 1;
79 return `${prefix}-${nsCounter}-${Math.random().toString(36).slice(2, 8)}`;
80 }
81 
82 it("creates a repo for a member and the route resolves immediately via D1", async () => {
83 const ns = uniqueNs("rc-ok");
84 const member = await createMember(ns);
85 const slug = "site";
86 const { status, payload } = await postCreate(member.cookieHeader, {
87 namespaceSlug: ns,
88 slug,
89 visibility: "private",
90 });
91 expect(status).toBe(200);
92 expect(payload).toMatchObject({
93 ok: true,
94 namespaceSlug: ns,
95 slug,
96 visibility: "private",
97 });
98 const route = await resolveRepositoryRoute(env, ns, slug);
99 expect(route).not.toBeNull();
100 expect(route?.namespaceId).toBe(member.namespaceId);
101 expect(route?.visibility).toBe("private");
102 // doName for fresh repos uses the `repo:<id-suffix>` form.
103 expect(route?.doName.startsWith("repo:")).toBe(true);
104 });
105 
106 it("rejects anonymous callers with 401", async () => {
107 const { status, payload } = await postCreate(null, {
108 namespaceSlug: "anonns",
109 slug: "x",
110 visibility: "public",
111 });
112 expect(status).toBe(401);
113 expect((payload as { error?: string }).error).toBe("Unauthorized");
114 });
115 
116 it("rejects non-members with 403 not-member", async () => {
117 const ns = uniqueNs("rc-nonmember");
118 await createMember(ns); // ns exists, but `intruder` is a separate user not a member.
119 const intruderNs = uniqueNs("rc-intruder");
120 const intruder = await createMember(intruderNs);
121 const { status, payload } = await postCreate(intruder.cookieHeader, {
122 namespaceSlug: ns,
123 slug: "anything",
124 visibility: "public",
125 });
126 expect(status).toBe(403);
127 expect(payload).toEqual({ ok: false, reason: "not-member" });
128 });
129 
130 it("rejects unknown namespace with 404 namespace-not-found", async () => {
131 const member = await createMember(uniqueNs("rc-known"));
132 const { status, payload } = await postCreate(member.cookieHeader, {
133 namespaceSlug: "ghost-namespace-xyz",
134 slug: "site",
135 visibility: "public",
136 });
137 expect(status).toBe(404);
138 expect(payload).toEqual({ ok: false, reason: "namespace-not-found" });
139 });
140 
141 it("rejects duplicate slug with 409 slug-taken", async () => {
142 const ns = uniqueNs("rc-dup");
143 const member = await createMember(ns);
144 await seedRepo(env, { namespaceSlug: ns, repoSlug: "site", userId: member.userId });
145 const { status, payload } = await postCreate(member.cookieHeader, {
146 namespaceSlug: ns,
147 slug: "site",
148 visibility: "public",
149 });
150 expect(status).toBe(409);
151 expect(payload).toEqual({ ok: false, reason: "slug-taken" });
152 });
153 
154 it("rejects invalid slug with 400 invalid-slug", async () => {
155 const ns = uniqueNs("rc-bad");
156 const member = await createMember(ns);
157 const { status, payload } = await postCreate(member.cookieHeader, {
158 namespaceSlug: ns,
159 slug: "Bad Slug!",
160 visibility: "private",
161 });
162 expect(status).toBe(400);
163 expect(payload).toEqual({ ok: false, reason: "invalid-slug" });
164 });
165 
166 it("rejects missing visibility with 400 invalid-visibility", async () => {
167 const ns = uniqueNs("rc-vis");
168 const member = await createMember(ns);
169 const { status, payload } = await postCreate(member.cookieHeader, {
170 namespaceSlug: ns,
171 slug: "site",
172 });
173 expect(status).toBe(400);
174 expect(payload).toEqual({ ok: false, reason: "invalid-visibility" });
175 });
176 
177 it("rejects same-origin violations (no Origin header)", async () => {
178 const ns = uniqueNs("rc-csrf");
179 const member = await createMember(ns);
180 const res = await workerExports.default.fetch("https://example.com/auth/api/repositories", {
181 method: "POST",
182 headers: { "Content-Type": "application/json", Cookie: member.cookieHeader },
183 body: JSON.stringify({ namespaceSlug: ns, slug: "x", visibility: "private" }),
184 });
185 expect(res.status).toBe(403);
186 });
187});
188 
189describe("PATCH /auth/api/repositories/:repositoryId", () => {
190 it("public->private flip enqueues route-cache-sync that removes the route KV record", async () => {
191 const ns = `rcv-${Math.random().toString(36).slice(2, 8)}`;
192 const member = await createMember(ns);
193 const seed = await seedRepo(env, {
194 namespaceSlug: ns,
195 repoSlug: "site",
196 userId: member.userId,
197 visibility: "public",
198 });
199 // Confirm KV record exists before flipping.
200 expect(await env.ROUTES.get(seed.routeCacheKey)).not.toBeNull();
201 const res = await workerExports.default.fetch(
202 `https://example.com/auth/api/repositories/${encodeURIComponent(seed.repositoryId)}`,
203 {
204 method: "PATCH",
205 headers: {
206 "Content-Type": "application/json",
207 Origin: "https://example.com",
208 Cookie: member.cookieHeader,
209 },
210 body: JSON.stringify({ visibility: "private" }),
211 }
212 );
213 expect(res.status).toBe(200);
214 const payload = (await res.json()) as {
215 ok: true;
216 visibility: "public" | "private";
217 previous: "public" | "private";
218 };
219 expect(payload.visibility).toBe("private");
220 expect(payload.previous).toBe("public");
221 // The request only enqueues; drive the consumer manually to converge.
222 // The consumer reads D1 (now private), so it deletes the canonical key.
223 const result = await runQueueMessage({
224 kind: "route-cache-sync",
225 repositoryId: seed.repositoryId,
226 namespaceSlug: ns,
227 repoSlug: "site",
228 enqueuedAt: Date.now(),
229 });
230 expect(result.acked).toBe(true);
231 expect(await env.ROUTES.get(seed.routeCacheKey)).toBeNull();
232 });
233 
234 it("rejects PATCH from non-members with 403 not-member", async () => {
235 const ns = `rcv-${Math.random().toString(36).slice(2, 8)}`;
236 const member = await createMember(ns);
237 const seed = await seedRepo(env, {
238 namespaceSlug: ns,
239 repoSlug: "site",
240 userId: member.userId,
241 });
242 const intruderNs = `rcv-int-${Math.random().toString(36).slice(2, 8)}`;
243 const intruder = await createMember(intruderNs);
244 const res = await workerExports.default.fetch(
245 `https://example.com/auth/api/repositories/${encodeURIComponent(seed.repositoryId)}`,
246 {
247 method: "PATCH",
248 headers: {
249 "Content-Type": "application/json",
250 Origin: "https://example.com",
251 Cookie: intruder.cookieHeader,
252 },
253 body: JSON.stringify({ visibility: "private" }),
254 }
255 );
256 expect(res.status).toBe(403);
257 expect(await res.json()).toEqual({ ok: false, reason: "not-member" });
258 });
259 
260 it("returns 404 not-found for unknown repository id", async () => {
261 const member = await createMember(`rcv-x-${Math.random().toString(36).slice(2, 8)}`);
262 const res = await workerExports.default.fetch(
263 `https://example.com/auth/api/repositories/repo_ghost`,
264 {
265 method: "PATCH",
266 headers: {
267 "Content-Type": "application/json",
268 Origin: "https://example.com",
269 Cookie: member.cookieHeader,
270 },
271 body: JSON.stringify({ visibility: "public" }),
272 }
273 );
274 expect(res.status).toBe(404);
275 expect(await res.json()).toEqual({ ok: false, reason: "not-found" });
276 });
277});
278 
279describe("public->private visibility flip changes anonymous read response", () => {
280 beforeEach(async () => {
281 await ensureD1Migrations(env);
282 });
283 
284 it("anonymous overview is 200 while public, 404 after flip; member overview stays 200", async () => {
285 const ns = `flip-${Math.random().toString(36).slice(2, 8)}`;
286 const member = await createMember(ns);
287 const seed = await seedRepo(env, {
288 namespaceSlug: ns,
289 repoSlug: "site",
290 userId: member.userId,
291 visibility: "public",
292 });
293 
294 const anonPublic = await workerExports.default.fetch(`https://example.com/${ns}/site`);
295 expect(anonPublic.status).toBe(200);
296 
297 const flipRes = await workerExports.default.fetch(
298 `https://example.com/auth/api/repositories/${encodeURIComponent(seed.repositoryId)}`,
299 {
300 method: "PATCH",
301 headers: {
302 "Content-Type": "application/json",
303 Origin: "https://example.com",
304 Cookie: member.cookieHeader,
305 },
306 body: JSON.stringify({ visibility: "private" }),
307 }
308 );
309 expect(flipRes.status).toBe(200);
310 
311 const anonPrivate = await workerExports.default.fetch(`https://example.com/${ns}/site`);
312 expect(anonPrivate.status).toBe(404);
313 
314 const memberPrivate = await workerExports.default.fetch(`https://example.com/${ns}/site`, {
315 headers: { Cookie: member.cookieHeader },
316 });
317 expect(memberPrivate.status).toBe(200);
318 });
319});