import { describe, expect, it } from 'vitest' import { processUpload } from '../../skills/data-portal/template/functions/api/upload' import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types' /** * The manifest is a Map keyed on objectKey, so the schema's `objectKey UNIQUE` * is real rather than assumed. Task 1704's lesson: a fake that accepts what the * store rejects passes a test the production path fails. */ function harness(opts: { folders?: string[]; grant?: string[] } = {}) { const folders = opts.folders ?? ['jobs'] const objects = new Map() const rows = new Map>() const lines: string[] = [] const env = { BUCKET: { async put(key: string, body: Uint8Array) { objects.set(key, body) }, async head(key: string) { const b = objects.get(key) return b ? { size: b.length } : null }, }, DB: { prepare(sql: string) { return { bind(...args: unknown[]) { return { async first() { if (sql.includes('FROM sessions')) { return { ownerId: 'alice', accountId: 'acct', folders: (opts.grant ?? []).join(','), } } if (sql.includes('isDir = 1')) { return folders.includes(String(args[1])) ? { relPath: args[1] } : null } if (sql.includes('SELECT fileId FROM manifest')) { return rows.get(String(args[0])) ?? null } return null }, async run() { // The upsert: conflict on objectKey replaces in place, which is // what the real ON CONFLICT (objectKey) does. const key = String(args[3]) rows.set(key, { fileId: args[0], ownerId: args[1], filename: args[2], objectKey: key, size: args[4], ingested: 0, targetPath: args[7], }) }, } }, } }, }, } as unknown as PortalEnv let n = 0 return { env, objects, rows, lines, newId: () => `id-${++n}`, log: (l: string) => lines.push(l), } } describe('targeted upload', () => { it('stores under the target and records it on the row', async () => { const h = harness() const res = await processUpload( 's', 'a.pdf', 'jobs', new Uint8Array([1, 2]), h.env, h.log, h.newId, () => 1000, ) expect(res.status).toBe(200) expect([...h.objects.keys()]).toEqual(['alice/jobs/a.pdf']) expect(h.rows.get('alice/jobs/a.pdf')!.targetPath).toBe('jobs') }) it('stores at the owner root when untargeted', async () => { const h = harness() const res = await processUpload( 's', 'a.pdf', '', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000, ) expect(res.status).toBe(200) expect([...h.objects.keys()]).toEqual(['alice/a.pdf']) }) it('keeps the same filename in two folders as two distinct objects', async () => { // The reason the target is in the key. A flat key would collide these and // the upsert would destroy the first upload. const h = harness({ folders: ['jobs', 'output'] }) await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000) await processUpload('s', 'a.pdf', 'output', new Uint8Array([2]), h.env, h.log, h.newId, () => 1000) expect([...h.objects.keys()].sort()).toEqual(['alice/jobs/a.pdf', 'alice/output/a.pdf']) expect(h.rows.size).toBe(2) }) it('replaces on the same owner, folder and filename', async () => { const h = harness() await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000) await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([2, 2]), h.env, h.log, h.newId, () => 2000) expect(h.rows.size).toBe(1) expect(h.rows.get('alice/jobs/a.pdf')!.size).toBe(2) }) it('refuses an out-of-grant target and stores nothing', async () => { const h = harness({ grant: ['output'] }) const res = await processUpload( 's', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000, ) expect(res.status).toBe(403) expect(h.objects.size).toBe(0) expect(h.rows.size).toBe(0) expect( h.lines.some((l) => l.includes('op=target-check') && l.includes('result=denied-grant')), ).toBe(true) }) it('refuses a target the index does not publish', async () => { const h = harness({ folders: [] }) const res = await processUpload( 's', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000, ) expect(res.status).toBe(403) expect(h.objects.size).toBe(0) expect(h.lines.some((l) => l.includes('result=denied-index'))).toBe(true) }) it('refuses a traversal target and stores nothing', async () => { const h = harness() const res = await processUpload( 's', 'a.pdf', 'jobs/../..', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000, ) expect(res.status).toBe(403) expect(h.objects.size).toBe(0) expect(h.lines.some((l) => l.includes('result=denied-segment'))).toBe(true) }) it('logs op=target-check result=allowed on the happy path', async () => { const h = harness() await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000) expect( h.lines.some((l) => l.includes('op=target-check') && l.includes('result=allowed')), ).toBe(true) }) it('checks the target before any store write', async () => { // Ordering is the point: a denied caller must not be able to cause an R2 // put, the same rule the owner gate already follows. const h = harness({ folders: [] }) await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000) const targetAt = h.lines.findIndex((l) => l.includes('op=target-check')) const putAt = h.lines.findIndex((l) => l.includes('op=r2-put')) expect(targetAt).toBeGreaterThanOrEqual(0) expect(putAt).toBe(-1) }) })