Skip to content
File

Blob: tests/worker/sites/asset-gate.workers.test.ts

typescript384 lines
1import { env } from "cloudflare:workers";
2import { beforeEach, describe, expect, it } from "vitest";
3 
4import { GRADIENT_PRESETS } from "@/shared/page-cover";
5import {
6 coverMetadata,
7 createSiteCoverHash,
8 SITE_COVER_FORMAT,
9 SITE_COVER_HEIGHT,
10 SITE_COVER_WIDTH,
11 siteCoverKey,
12 type SiteCoverSource,
13} from "@/worker/sites/cover";
14import { resetD1Tables } from "@tests/worker/helpers/db";
15import { apiRequest } from "@tests/worker/helpers/request";
16import { expectSitesStaticDocumentPreloadLinks } from "@tests/worker/helpers/sites";
17import {
18 seedPage,
19 seedPublishedPage,
20 seedUpload,
21 seedUser,
22 seedWorkspace,
23 seedWorkspaceSite,
24} from "@tests/worker/helpers/seeds";
25 
26const SUBDOMAIN_ORIGIN = "https://acme.sites.test";
27 
28async function putR2(key: string, body: string): Promise<void> {
29 await env.R2.put(key, body, { httpMetadata: { contentType: "image/png" } });
30}
31 
32async function responseTextFromBytes(response: Response): Promise<string> {
33 return new TextDecoder().decode(await response.arrayBuffer());
34}
35 
36async function putSitesCover(
37 workspaceId: string,
38 pageId: string,
39 coverUrl: string,
40 body = "PNG_BYTES",
41 source: SiteCoverSource = "gradient",
42): Promise<void> {
43 const coverHash = await createSiteCoverHash(coverUrl);
44 await env.SITES.put(siteCoverKey(workspaceId, pageId), body, {
45 httpMetadata: { contentType: SITE_COVER_FORMAT },
46 customMetadata: coverMetadata(coverUrl, coverHash, source),
47 });
48}
49 
50describe("Sites asset gate", () => {
51 beforeEach(async () => {
52 await resetD1Tables();
53 // Best-effort R2 cleanup. Miniflare R2 persists across tests in some configs,
54 // so explicit deletes are safer than relying on test isolation.
55 const [r2Listing, sitesListing] = await Promise.all([env.R2.list(), env.SITES.list()]);
56 await Promise.all([
57 ...r2Listing.objects.map((o) => env.R2.delete(o.key)),
58 ...sitesListing.objects.map((o) => env.SITES.delete(o.key)),
59 ]);
60 });
61 
62 it("serves a public asset for a published page", async () => {
63 const owner = await seedUser();
64 const ws = await seedWorkspace({ owner_id: owner.id });
65 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Page" });
66 const upload = await seedUpload({
67 workspace_id: ws.id,
68 uploaded_by: owner.id,
69 page_id: page.id,
70 r2_key: `${ws.id}/${page.id}/cover.png`,
71 });
72 await putR2(upload.r2_key, "PNG_BYTES");
73 await seedWorkspaceSite({ workspace_id: ws.id });
74 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
75 
76 const res = await apiRequest(`/_assets/${page.id}/${upload.id}`, { origin: SUBDOMAIN_ORIGIN });
77 expect(res.status).toBe(200);
78 expect(res.headers.get("content-type")).toBe("image/png");
79 expect(res.headers.get("cache-control")).toContain("max-age=300");
80 const bytes = new Uint8Array(await res.arrayBuffer());
81 expect(new TextDecoder().decode(bytes)).toBe("PNG_BYTES");
82 });
83 
84 it("404s when the page is unpublished", async () => {
85 const owner = await seedUser();
86 const ws = await seedWorkspace({ owner_id: owner.id });
87 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
88 const upload = await seedUpload({
89 workspace_id: ws.id,
90 uploaded_by: owner.id,
91 page_id: page.id,
92 r2_key: `${ws.id}/${page.id}/cover.png`,
93 });
94 await putR2(upload.r2_key, "PNG");
95 await seedWorkspaceSite({ workspace_id: ws.id });
96 // No publish row.
97 
98 const res = await apiRequest(`/_assets/${page.id}/${upload.id}`, { origin: SUBDOMAIN_ORIGIN });
99 expect(res.status).toBe(404);
100 });
101 
102 it("404s when the upload belongs to a different page", async () => {
103 const owner = await seedUser();
104 const ws = await seedWorkspace({ owner_id: owner.id });
105 const pageA = await seedPage({ workspace_id: ws.id, created_by: owner.id });
106 const pageB = await seedPage({ workspace_id: ws.id, created_by: owner.id });
107 const upload = await seedUpload({
108 workspace_id: ws.id,
109 uploaded_by: owner.id,
110 page_id: pageA.id,
111 r2_key: `${ws.id}/${pageA.id}/cover.png`,
112 });
113 await putR2(upload.r2_key, "PNG");
114 await seedWorkspaceSite({ workspace_id: ws.id });
115 await seedPublishedPage({ workspace_id: ws.id, page_id: pageB.id, published_by: owner.id });
116 
117 const res = await apiRequest(`/_assets/${pageB.id}/${upload.id}`, { origin: SUBDOMAIN_ORIGIN });
118 expect(res.status).toBe(404);
119 expectSitesStaticDocumentPreloadLinks(res);
120 });
121 
122 it("404s when the upload belongs to a different workspace", async () => {
123 const ownerA = await seedUser();
124 const ownerB = await seedUser();
125 const wsA = await seedWorkspace({ owner_id: ownerA.id, slug: "ws-a" });
126 const wsB = await seedWorkspace({ owner_id: ownerB.id, slug: "ws-b" });
127 const pageA = await seedPage({ workspace_id: wsA.id, created_by: ownerA.id });
128 const pageB = await seedPage({ workspace_id: wsB.id, created_by: ownerB.id });
129 const uploadB = await seedUpload({
130 workspace_id: wsB.id,
131 uploaded_by: ownerB.id,
132 page_id: pageB.id,
133 r2_key: `${wsB.id}/${pageB.id}/cover.png`,
134 });
135 await putR2(uploadB.r2_key, "PNG");
136 await seedWorkspaceSite({ workspace_id: wsA.id });
137 await seedPublishedPage({ workspace_id: wsA.id, page_id: pageA.id, published_by: ownerA.id });
138 
139 const res = await apiRequest(`/_assets/${pageA.id}/${uploadB.id}`, { origin: SUBDOMAIN_ORIGIN });
140 expect(res.status).toBe(404);
141 });
142 
143 it("404s when the R2 object is missing despite a valid D1 row", async () => {
144 const owner = await seedUser();
145 const ws = await seedWorkspace({ owner_id: owner.id });
146 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
147 const upload = await seedUpload({
148 workspace_id: ws.id,
149 uploaded_by: owner.id,
150 page_id: page.id,
151 r2_key: `${ws.id}/${page.id}/cover.png`,
152 });
153 // Deliberately do NOT put the R2 object.
154 await seedWorkspaceSite({ workspace_id: ws.id });
155 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
156 
157 const res = await apiRequest(`/_assets/${page.id}/${upload.id}`, { origin: SUBDOMAIN_ORIGIN });
158 expect(res.status).toBe(404);
159 });
160 
161 it("404s when the upload id does not exist", async () => {
162 const owner = await seedUser();
163 const ws = await seedWorkspace({ owner_id: owner.id });
164 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
165 await seedWorkspaceSite({ workspace_id: ws.id });
166 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
167 
168 const res = await apiRequest(`/_assets/${page.id}/01ZZZZ`, { origin: SUBDOMAIN_ORIGIN });
169 expect(res.status).toBe(404);
170 });
171 
172 it("does not stream original uploads through the cover route", async () => {
173 const owner = await seedUser();
174 const ws = await seedWorkspace({ owner_id: owner.id });
175 const page = await seedPage({
176 workspace_id: ws.id,
177 created_by: owner.id,
178 title: "Upload Cover",
179 cover_url: "/uploads/upload-cover",
180 });
181 const upload = await seedUpload({
182 id: "upload-cover",
183 workspace_id: ws.id,
184 uploaded_by: owner.id,
185 page_id: page.id,
186 r2_key: `${ws.id}/${page.id}/cover.webp`,
187 content_type: "image/webp",
188 });
189 await putR2(upload.r2_key, "WEBP_BYTES");
190 await seedWorkspaceSite({ workspace_id: ws.id });
191 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
192 
193 const res = await apiRequest(`/_assets/${page.id}/cover`, { origin: SUBDOMAIN_ORIGIN });
194 expect(res.status).toBe(503);
195 expect(res.headers.get("content-type")).toBeNull();
196 expect(await res.text()).toBe("");
197 });
198 
199 it("serves matching uploaded derived cover artifacts", async () => {
200 const owner = await seedUser();
201 const ws = await seedWorkspace({ owner_id: owner.id });
202 const coverUrl = "/uploads/upload-cover";
203 const page = await seedPage({
204 workspace_id: ws.id,
205 created_by: owner.id,
206 title: "Upload Cover",
207 cover_url: coverUrl,
208 });
209 const upload = await seedUpload({
210 id: "upload-cover",
211 workspace_id: ws.id,
212 uploaded_by: owner.id,
213 page_id: page.id,
214 r2_key: `${ws.id}/${page.id}/cover.webp`,
215 content_type: "image/webp",
216 });
217 await putR2(upload.r2_key, "WEBP_BYTES");
218 await putSitesCover(ws.id, page.id, coverUrl, "DERIVED_PNG", "upload");
219 await seedWorkspaceSite({ workspace_id: ws.id });
220 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
221 
222 const res = await apiRequest(`/_assets/${page.id}/cover`, {
223 origin: SUBDOMAIN_ORIGIN,
224 search: { v: await createSiteCoverHash(coverUrl) },
225 });
226 expect(res.status).toBe(200);
227 expect(res.headers.get("content-type")).toBe(SITE_COVER_FORMAT);
228 expect(res.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
229 expect(await responseTextFromBytes(res)).toBe("DERIVED_PNG");
230 });
231 
232 it("404s upload covers that are not scoped to the current page", async () => {
233 const owner = await seedUser();
234 const ws = await seedWorkspace({ owner_id: owner.id });
235 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/other-cover" });
236 const otherPage = await seedPage({ workspace_id: ws.id, created_by: owner.id });
237 const upload = await seedUpload({
238 id: "other-cover",
239 workspace_id: ws.id,
240 uploaded_by: owner.id,
241 page_id: otherPage.id,
242 r2_key: `${ws.id}/${otherPage.id}/cover.png`,
243 });
244 await putR2(upload.r2_key, "PNG_BYTES");
245 await seedWorkspaceSite({ workspace_id: ws.id });
246 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
247 
248 const res = await apiRequest(`/_assets/${page.id}/cover`, { origin: SUBDOMAIN_ORIGIN });
249 expect(res.status).toBe(404);
250 expect(res.headers.get("content-type")).toBeNull();
251 expect(await res.text()).toBe("");
252 });
253 
254 it("404s unsafe upload cover types with an empty response", async () => {
255 const owner = await seedUser();
256 const ws = await seedWorkspace({ owner_id: owner.id });
257 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/unsafe-cover" });
258 const upload = await seedUpload({
259 id: "unsafe-cover",
260 workspace_id: ws.id,
261 uploaded_by: owner.id,
262 page_id: page.id,
263 content_type: "image/heic",
264 r2_key: `${ws.id}/${page.id}/cover.heic`,
265 });
266 await putR2(upload.r2_key, "HEIC_BYTES");
267 await seedWorkspaceSite({ workspace_id: ws.id });
268 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
269 
270 const res = await apiRequest(`/_assets/${page.id}/cover`, { origin: SUBDOMAIN_ORIGIN });
271 expect(res.status).toBe(404);
272 expect(res.headers.get("content-type")).toBeNull();
273 expect(await res.text()).toBe("");
274 });
275 
276 it("404s upload covers when the original upload object is missing", async () => {
277 const owner = await seedUser();
278 const ws = await seedWorkspace({ owner_id: owner.id });
279 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/missing-cover" });
280 await seedUpload({
281 id: "missing-cover",
282 workspace_id: ws.id,
283 uploaded_by: owner.id,
284 page_id: page.id,
285 r2_key: `${ws.id}/${page.id}/missing.png`,
286 });
287 await seedWorkspaceSite({ workspace_id: ws.id });
288 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
289 
290 const res = await apiRequest(`/_assets/${page.id}/cover`, { origin: SUBDOMAIN_ORIGIN });
291 expect(res.status).toBe(404);
292 expect(res.headers.get("content-type")).toBeNull();
293 expect(await res.text()).toBe("");
294 });
295 
296 it("404s unpublished cover requests even when the upload object exists", async () => {
297 const owner = await seedUser();
298 const ws = await seedWorkspace({ owner_id: owner.id });
299 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: "/uploads/cover-upload" });
300 const upload = await seedUpload({
301 id: "cover-upload",
302 workspace_id: ws.id,
303 uploaded_by: owner.id,
304 page_id: page.id,
305 r2_key: `${ws.id}/${page.id}/cover.png`,
306 });
307 await putR2(upload.r2_key, "PNG_BYTES");
308 await seedWorkspaceSite({ workspace_id: ws.id });
309 
310 const res = await apiRequest(`/_assets/${page.id}/cover`, { origin: SUBDOMAIN_ORIGIN });
311 expect(res.status).toBe(404);
312 expect(await res.text()).toBe("");
313 });
314 
315 it("serves matching generated cover artifacts and caches versioned URLs immutably", async () => {
316 const owner = await seedUser();
317 const ws = await seedWorkspace({ owner_id: owner.id });
318 const cover = GRADIENT_PRESETS[0];
319 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: cover });
320 await putSitesCover(ws.id, page.id, cover, "PNG_BYTES");
321 await seedWorkspaceSite({ workspace_id: ws.id });
322 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
323 
324 const res = await apiRequest(`/_assets/${page.id}/cover`, {
325 origin: SUBDOMAIN_ORIGIN,
326 search: { v: await createSiteCoverHash(cover) },
327 });
328 expect(res.status).toBe(200);
329 expect(res.headers.get("content-type")).toBe("image/png");
330 expect(res.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
331 expect(await responseTextFromBytes(res)).toBe("PNG_BYTES");
332 });
333 
334 it("503s missing or stale generated cover artifacts with an empty repair response", async () => {
335 const owner = await seedUser();
336 const ws = await seedWorkspace({ owner_id: owner.id });
337 const cover = GRADIENT_PRESETS[0];
338 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: cover });
339 await seedWorkspaceSite({ workspace_id: ws.id });
340 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
341 
342 const missing = await apiRequest(`/_assets/${page.id}/cover`, { origin: SUBDOMAIN_ORIGIN });
343 expect(missing.status).toBe(503);
344 expect(missing.headers.get("cache-control")).toBe("no-store");
345 expect(missing.headers.get("retry-after")).toBe("5");
346 expect(missing.headers.get("content-type")).toBeNull();
347 expect(await missing.text()).toBe("");
348 
349 await env.SITES.put(siteCoverKey(ws.id, page.id), "STALE", {
350 httpMetadata: { contentType: "image/png" },
351 customMetadata: {
352 cover_hash: "old",
353 cover_url: cover,
354 width: String(SITE_COVER_WIDTH),
355 height: String(SITE_COVER_HEIGHT),
356 },
357 });
358 
359 const stale = await apiRequest(`/_assets/${page.id}/cover`, { origin: SUBDOMAIN_ORIGIN });
360 expect(stale.status).toBe(503);
361 expect(stale.headers.get("content-type")).toBeNull();
362 expect(await stale.text()).toBe("");
363 });
364 
365 it("404s old versioned generated cover URLs after the cover changes", async () => {
366 const owner = await seedUser();
367 const ws = await seedWorkspace({ owner_id: owner.id });
368 const currentCover = GRADIENT_PRESETS[1];
369 const oldHash = await createSiteCoverHash(GRADIENT_PRESETS[0]);
370 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, cover_url: currentCover });
371 await putSitesCover(ws.id, page.id, currentCover);
372 await seedWorkspaceSite({ workspace_id: ws.id });
373 await seedPublishedPage({ workspace_id: ws.id, page_id: page.id, published_by: owner.id });
374 
375 const res = await apiRequest(`/_assets/${page.id}/cover`, {
376 origin: SUBDOMAIN_ORIGIN,
377 search: { v: oldHash },
378 });
379 expect(res.status).toBe(404);
380 expect(res.headers.get("content-type")).toBeNull();
381 expect(await res.text()).toBe("");
382 });
383});