|
14 | 14 | // bearer reaches `/api/v1/mcp`, the bridge reads the record through |
15 | 15 | // `get_record`'s door under the showcase's own owner RLS and |
16 | 16 | // `controlled_by_parent` derivation, the storage plugin answers, and the link |
17 | | -// is spent through the real `PUT /api/v1/storage/upload-links/:token`. |
| 17 | +// is spent through the real `PUT /api/v1/storage/upload-links/:token` by the |
| 18 | +// user it was minted for, signed in (the link is not an anonymous door: no |
| 19 | +// session answers 401, as every other storage door does). |
18 | 20 | // |
19 | 21 | // The records: `showcase_invoice` (owner RLS on `created_by`, `enable.files`, |
20 | 22 | // so it holds attachments) and `showcase_invoice_line` (`controlled_by_parent`, |
@@ -42,7 +44,8 @@ const MIB = 1024 * 1024; |
42 | 44 | const READ_CAP = 10 * MIB; |
43 | 45 | /** `UPLOAD_LINK_TTL_SECONDS` in `@objectstack/service-storage`. */ |
44 | 46 | const LINK_TTL_MS = 600_000; |
45 | | -const UNUSABLE = 'This upload link cannot be used: it was used already, it has expired, or it does not exist. Ask for a new link.'; |
| 47 | +const UNUSABLE = |
| 48 | + 'This upload link cannot be used: it was used already, it has expired, it was made for someone else, or it does not exist. Ask for a new link.'; |
46 | 49 |
|
47 | 50 | // The member's set: CRUD on the invoice graph, so a request reaches the RLS |
48 | 51 | // layer, plus an OWNER policy on the master invoice. The line carries no RLS of |
@@ -141,9 +144,17 @@ describe('[#30892] showcase: an agent reads and attaches a record\'s files over |
141 | 144 | return { path: toPath(json.url), answer: json }; |
142 | 145 | } |
143 | 146 |
|
144 | | - /** Spend a link: an anonymous PUT of `body`, as a browser page or `curl -T` sends it. */ |
145 | | - const put = (path: string, body: Buffer, headers: Record<string, string> = {}) => |
146 | | - stack.raw(path, { method: 'PUT', headers: { 'content-type': 'application/pdf', 'x-file-name': 'receipt.pdf', ...headers }, body }); |
| 147 | + /** The credential a link request carries: the member's unless named, none for `null`. */ |
| 148 | + const authOf = (token: string | null): Record<string, string> => (token === null ? {} : { Authorization: `Bearer ${token}` }); |
| 149 | + /** Open a link's page, signed in as `token`'s user. */ |
| 150 | + const open = (path: string, token: string | null = memberToken) => stack.raw(path, { headers: authOf(token) }); |
| 151 | + /** Spend a link: a PUT of `body`, signed in as `token`'s user, as the page's script or `curl -T` sends it. */ |
| 152 | + const put = (path: string, body: Buffer, headers: Record<string, string> = {}, token: string | null = memberToken) => |
| 153 | + stack.raw(path, { |
| 154 | + method: 'PUT', |
| 155 | + headers: { 'content-type': 'application/pdf', 'x-file-name': 'receipt.pdf', ...headers, ...authOf(token) }, |
| 156 | + body, |
| 157 | + }); |
147 | 158 |
|
148 | 159 | const line = (id: string) => ql.findOne('showcase_invoice_line', { where: { id }, ...SYS }); |
149 | 160 | /** The file id a line's `receipt` holds; a read expands a file reference to its file. */ |
@@ -217,7 +228,7 @@ describe('[#30892] showcase: an agent reads and attaches a record\'s files over |
217 | 228 |
|
218 | 229 | // The page a person opens: no cache, no referrer, a CSP, and nothing |
219 | 230 | // about the record or the user on it. |
220 | | - const page = await stack.raw(path); |
| 231 | + const page = await open(path); |
221 | 232 | expect(page.status).toBe(200); |
222 | 233 | expect(page.headers.get('cache-control')).toContain('no-store'); |
223 | 234 | expect(page.headers.get('referrer-policy')).toBe('no-referrer'); |
@@ -255,21 +266,40 @@ describe('[#30892] showcase: an agent reads and attaches a record\'s files over |
255 | 266 | const again = await put(path, minimalPdf('Replaced')); |
256 | 267 | expect(again.status).toBe(404); |
257 | 268 | expect(((await again.json()) as any).error).toMatchObject({ code: 'RESOURCE_NOT_FOUND', message: UNUSABLE }); |
258 | | - expect((await stack.raw(path)).status).toBe(404); |
| 269 | + expect((await open(path)).status).toBe(404); |
259 | 270 | // An unknown token answers exactly the same. |
260 | 271 | const unknown = await put('/api/v1/storage/upload-links/00000000-0000-4000-8000-000000000000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', pdf); |
261 | 272 | expect(unknown.status).toBe(404); |
262 | 273 | expect(((await unknown.json()) as any).error).toMatchObject({ code: 'RESOURCE_NOT_FOUND', message: UNUSABLE }); |
263 | 274 | fileId = (await receiptOf(memberLine))!; |
264 | 275 | }); |
265 | 276 |
|
| 277 | + it('works only for the member, signed in: anonymous is 401, another signed-in user the same 404, and neither spends it', async () => { |
| 278 | + const { path } = await mint({ objectName: 'showcase_invoice_line', recordId: memberLine, field: 'receipt' }); |
| 279 | + const held = await receiptOf(memberLine); |
| 280 | + for (const res of [await open(path, null), await put(path, minimalPdf('Anonymous'), {}, null)]) { |
| 281 | + expect(res.status).toBe(401); |
| 282 | + expect(((await res.json()) as any).error.code).toBe('AUTH_REQUIRED'); |
| 283 | + } |
| 284 | + expect((await open(path, adminToken)).status).toBe(404); |
| 285 | + const stranger = await put(path, minimalPdf('Not yours'), {}, adminToken); |
| 286 | + expect(stranger.status).toBe(404); |
| 287 | + expect(((await stranger.json()) as any).error).toMatchObject({ code: 'RESOURCE_NOT_FOUND', message: UNUSABLE }); |
| 288 | + expect(await receiptOf(memberLine)).toBe(held); |
| 289 | + // Still open: the member spends it. |
| 290 | + const own = await put(path, pdf); |
| 291 | + expect(own.status).toBe(200); |
| 292 | + fileId = ((await own.json()) as any).data.fileId; |
| 293 | + expect(await receiptOf(memberLine)).toBe(fileId); |
| 294 | + }); |
| 295 | + |
266 | 296 | it('expires: past ten minutes the page and the upload answer the same 404, and nothing lands', async () => { |
267 | 297 | const { path } = await mint({ objectName: 'showcase_invoice_line', recordId: memberLine, field: 'receipt' }); |
268 | 298 | const held = await receiptOf(memberLine); |
269 | 299 | const now = Date.now(); |
270 | 300 | const spy = vi.spyOn(Date, 'now').mockReturnValue(now + LINK_TTL_MS + 1_000); |
271 | 301 | try { |
272 | | - expect((await stack.raw(path)).status).toBe(404); |
| 302 | + expect((await open(path)).status).toBe(404); |
273 | 303 | const late = await put(path, minimalPdf('Late')); |
274 | 304 | expect(late.status).toBe(404); |
275 | 305 | expect(((await late.json()) as any).error).toMatchObject({ code: 'RESOURCE_NOT_FOUND', message: UNUSABLE }); |
@@ -330,7 +360,7 @@ describe('[#30892] showcase: an agent reads and attaches a record\'s files over |
330 | 360 |
|
331 | 361 | it('access follows the record: the admin\'s invoice is refused, and its attachment cannot be read through the member\'s own invoice', async () => { |
332 | 362 | const { path } = await mint({ objectName: 'showcase_invoice', recordId: adminInvoice, attachments: true }, adminToken); |
333 | | - const res = await put(path, Buffer.from('admin only'), { 'content-type': 'text/plain', 'x-file-name': 'admin.txt' }); |
| 363 | + const res = await put(path, Buffer.from('admin only'), { 'content-type': 'text/plain', 'x-file-name': 'admin.txt' }, adminToken); |
334 | 364 | expect(res.status).toBe(200); |
335 | 365 | adminAttachmentId = ((await res.json()) as any).data.attachmentId; |
336 | 366 |
|
|
0 commit comments