File
Blob: tests/worker/routes/upload-surface-precedence.workers.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { eq } from "drizzle-orm"; |
| 3 | import { beforeEach, describe, expect, it } from "vitest"; |
| 4 | |
| 5 | import { uploads } from "@/worker/db/d1/schema"; |
| 6 | import { apiRequest } from "@tests/worker/helpers/request"; |
| 7 | import { refreshCookieFor } from "@tests/worker/helpers/auth"; |
| 8 | import { getDb, resetD1Tables } from "@tests/worker/helpers/db"; |
| 9 | import { |
| 10 | seedMembership, |
| 11 | seedPage, |
| 12 | seedPageShare, |
| 13 | seedUpload, |
| 14 | seedUser, |
| 15 | seedWorkspace, |
| 16 | } from "@tests/worker/helpers/seeds"; |
| 17 | |
| 18 | async 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 | |
| 29 | describe("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 | }); |