Skip to content
File

Blob: tests/worker/routes/upload-surface-precedence.workers.test.ts

typescript249 lines
1import { env } from "cloudflare:workers";
2import { eq } from "drizzle-orm";
3import { beforeEach, describe, expect, it } from "vitest";
4 
5import { uploads } from "@/worker/db/d1/schema";
6import { apiRequest } from "@tests/worker/helpers/request";
7import { refreshCookieFor } from "@tests/worker/helpers/auth";
8import { getDb, resetD1Tables } from "@tests/worker/helpers/db";
9import {
10 seedMembership,
11 seedPage,
12 seedPageShare,
13 seedUpload,
14 seedUser,
15 seedWorkspace,
16} from "@tests/worker/helpers/seeds";
17 
18async function resetR2() {
19 let cursor: string | undefined;
20 do {
21 const list = await env.R2.list({ cursor });
22 if (list.objects.length > 0) {
23 await env.R2.delete(list.objects.map((o) => o.key));
24 }
25 cursor = list.truncated ? list.cursor : undefined;
26 } while (cursor);
27}
28 
29describe("uploads: shared-surface precedence", () => {
30 beforeEach(async () => {
31 await resetD1Tables();
32 await resetR2();
33 });
34 
35 describe("POST /workspaces/:wid/uploads/presign", () => {
36 it("rejects a member with ?share= on a page where the share is view-only", async () => {
37 const owner = await seedUser();
38 const member = await seedUser();
39 const ws = await seedWorkspace({ owner_id: owner.id });
40 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
41 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
42 await seedPageShare({
43 page_id: page.id,
44 created_by: owner.id,
45 grantee_type: "link",
46 grantee_id: null,
47 link_token: "tok-view-only",
48 permission: "view",
49 });
50 
51 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/uploads/presign`, {
52 method: "POST",
53 body: { filename: "f.png", content_type: "image/png", size_bytes: 1024, page_id: page.id },
54 userId: member.id,
55 shareToken: "tok-view-only",
56 });
57 
58 expect(res.status).toBe(403);
59 });
60 
61 it("uses the share creator as uploaded_by when ?share= grants edit (even for a canonical member)", async () => {
62 const owner = await seedUser();
63 const shareCreator = await seedUser();
64 const member = await seedUser();
65 const ws = await seedWorkspace({ owner_id: owner.id });
66 await seedMembership({ user_id: shareCreator.id, workspace_id: ws.id, role: "member" });
67 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
68 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
69 await seedPageShare({
70 page_id: page.id,
71 created_by: shareCreator.id,
72 grantee_type: "link",
73 grantee_id: null,
74 link_token: "tok-edit",
75 permission: "edit",
76 });
77 
78 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/uploads/presign`, {
79 method: "POST",
80 body: { filename: "f.png", content_type: "image/png", size_bytes: 1024, page_id: page.id },
81 userId: member.id,
82 shareToken: "tok-edit",
83 });
84 
85 expect(res.status).toBe(200);
86 const body = (await res.json()) as { upload: { id: string } };
87 const uploadRow = await getDb().select().from(uploads).where(eq(uploads.id, body.upload.id)).get();
88 expect(uploadRow?.uploaded_by).toBe(shareCreator.id);
89 });
90 
91 it("uses the caller id as uploaded_by when no share is presented (canonical member path)", async () => {
92 const owner = await seedUser();
93 const member = await seedUser();
94 const ws = await seedWorkspace({ owner_id: owner.id });
95 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
96 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
97 
98 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/uploads/presign`, {
99 method: "POST",
100 body: { filename: "f.png", content_type: "image/png", size_bytes: 1024, page_id: page.id },
101 userId: member.id,
102 });
103 
104 expect(res.status).toBe(200);
105 const body = (await res.json()) as { upload: { id: string } };
106 const uploadRow = await getDb().select().from(uploads).where(eq(uploads.id, body.upload.id)).get();
107 expect(uploadRow?.uploaded_by).toBe(member.id);
108 });
109 });
110 
111 describe("PUT /uploads/:id/data", () => {
112 it("authorizes a member with ?share= via the share principal, not uploaded_by match", async () => {
113 const owner = await seedUser();
114 const shareCreator = await seedUser();
115 const member = await seedUser();
116 const ws = await seedWorkspace({ owner_id: owner.id });
117 await seedMembership({ user_id: shareCreator.id, workspace_id: ws.id, role: "member" });
118 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
119 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
120 await seedPageShare({
121 page_id: page.id,
122 created_by: shareCreator.id,
123 grantee_type: "link",
124 grantee_id: null,
125 link_token: "tok-edit-put",
126 permission: "edit",
127 });
128 const upload = await seedUpload({
129 workspace_id: ws.id,
130 page_id: page.id,
131 uploaded_by: shareCreator.id,
132 filename: "f.png",
133 size_bytes: 4,
134 content_type: "image/png",
135 r2_key: `${ws.id}/share-uploaded-id/f.png`,
136 });
137 
138 const res = await apiRequest(`/uploads/${upload.id}/data`, {
139 method: "PUT",
140 headers: { "content-type": "image/png" },
141 body: new Uint8Array([1, 2, 3, 4]).buffer,
142 userId: member.id,
143 shareToken: "tok-edit-put",
144 });
145 
146 expect(res.status).toBe(200);
147 });
148 
149 it("denies a member with ?share= when the share does not grant edit (no canonical fallback)", async () => {
150 const owner = await seedUser();
151 const member = await seedUser();
152 const ws = await seedWorkspace({ owner_id: owner.id });
153 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
154 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
155 await seedPageShare({
156 page_id: page.id,
157 created_by: owner.id,
158 grantee_type: "link",
159 grantee_id: null,
160 link_token: "tok-view-put",
161 permission: "view",
162 });
163 const upload = await seedUpload({
164 workspace_id: ws.id,
165 page_id: page.id,
166 uploaded_by: member.id,
167 filename: "f.png",
168 size_bytes: 4,
169 content_type: "image/png",
170 r2_key: `${ws.id}/member-uploaded-id/f.png`,
171 });
172 
173 const res = await apiRequest(`/uploads/${upload.id}/data`, {
174 method: "PUT",
175 headers: { "content-type": "image/png" },
176 body: new Uint8Array([1, 2, 3, 4]).buffer,
177 userId: member.id,
178 shareToken: "tok-view-put",
179 });
180 
181 expect(res.status).toBe(403);
182 });
183 });
184 
185 describe("GET /uploads/:id", () => {
186 it("serves a page-scoped asset via share principal when ?share= is provided", async () => {
187 const owner = await seedUser();
188 const ws = await seedWorkspace({ owner_id: owner.id });
189 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
190 await seedPageShare({
191 page_id: page.id,
192 created_by: owner.id,
193 grantee_type: "link",
194 grantee_id: null,
195 link_token: "tok-view-get",
196 permission: "view",
197 });
198 const upload = await seedUpload({
199 workspace_id: ws.id,
200 page_id: page.id,
201 uploaded_by: owner.id,
202 r2_key: `${ws.id}/share-get/f.png`,
203 content_type: "image/png",
204 });
205 await env.R2.put(upload.r2_key, new Uint8Array([1, 2, 3]));
206 
207 const res = await apiRequest(`/uploads/${upload.id}`, { shareToken: "tok-view-get" });
208 expect(res.status).toBe(200);
209 expect(res.headers.get("cache-control")).toBe("private, max-age=300, must-revalidate");
210 });
211 
212 it("returns 401 when ?share= token is invalid and no cookie provides canonical auth fallback", async () => {
213 const owner = await seedUser();
214 const ws = await seedWorkspace({ owner_id: owner.id });
215 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
216 const upload = await seedUpload({
217 workspace_id: ws.id,
218 page_id: page.id,
219 uploaded_by: owner.id,
220 r2_key: `${ws.id}/bad-share/f.png`,
221 });
222 await env.R2.put(upload.r2_key, new Uint8Array([1]));
223 
224 const res = await apiRequest(`/uploads/${upload.id}`, { shareToken: "tok-does-not-exist" });
225 // Local loopback converts 401 to 403 at the router layer.
226 expect([401, 403]).toContain(res.status);
227 });
228 
229 it("keeps long cache-control for workspace-level assets (no page_id)", async () => {
230 const owner = await seedUser();
231 const ws = await seedWorkspace({ owner_id: owner.id });
232 const upload = await seedUpload({
233 workspace_id: ws.id,
234 page_id: null,
235 uploaded_by: owner.id,
236 r2_key: `${ws.id}/avatar.png`,
237 content_type: "image/png",
238 });
239 await env.R2.put(upload.r2_key, new Uint8Array([7, 8, 9]));
240 
241 const cookie = await refreshCookieFor(owner.id);
242 const res = await apiRequest(`/uploads/${upload.id}`, { cookie });
243 
244 expect(res.status).toBe(200);
245 expect(res.headers.get("cache-control")).toBe("private, max-age=31536000, immutable");
246 });
247 });
248});