import { describe, it, expect } from 'vitest' import { processFiles } from '../../skills/data-portal/template/functions/api/files' import { processDownload } from '../../skills/data-portal/template/functions/api/download' import { processDelete } from '../../skills/data-portal/template/functions/api/delete' import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types' // alice's session is 'sess-a'; bob owns 'bob/secret.pdf'. function makeEnv() { const manifest = [ { fileId: 'f1', ownerId: 'alice', filename: 'a.pdf', objectKey: 'alice/a.pdf', size: 10 }, { fileId: 'f2', ownerId: 'bob', filename: 'secret.pdf', objectKey: 'bob/secret.pdf', size: 20 }, ] const objects = new Map([ ['alice/a.pdf', 'A'], ['bob/secret.pdf', 'B'], ]) const deleted: string[] = [] const env = { DB: { prepare(query: string) { let bound: unknown[] = [] const stmt = { bind(...v: unknown[]) { bound = v return stmt }, async run() { if (/DELETE FROM manifest/i.test(query)) { const i = manifest.findIndex((m) => m.objectKey === bound[0]) if (i >= 0) manifest.splice(i, 1) } return { meta: { changes: 1 } } }, async all() { const owner = bound[0] as string return { results: manifest.filter((m) => m.ownerId === owner) as T[] } }, async first() { if (/FROM sessions/i.test(query)) { const map: Record = { 'sess-a': 'alice', 'sess-b': 'bob' } const owner = map[bound[0] as string] return (owner ? { ownerId: owner } : null) as T | null } return (manifest.find((m) => m.objectKey === bound[0]) ?? null) as T | null }, } return stmt }, }, BUCKET: { async get(key: string) { const v = objects.get(key) return v ? { body: null, size: v.length } : null }, async delete(key: string) { deleted.push(key) objects.delete(key) }, async put() { return {} }, async head(key: string) { const v = objects.get(key) return v ? { size: v.length } : null }, }, } as unknown as PortalEnv return { env, deleted, manifest, objects } } describe('processFiles', () => { it("lists only the session owner's files", async () => { const { env } = makeEnv() const res = await processFiles('sess-a', env, () => {}, 1_000_000) expect(res.status).toBe(200) const files = res.payload.files as Array<{ objectKey: string }> expect(files.map((f) => f.objectKey)).toEqual(['alice/a.pdf']) }) it("never includes another owner's file", async () => { const { env } = makeEnv() const res = await processFiles('sess-a', env, () => {}, 1_000_000) expect(JSON.stringify(res.payload)).not.toContain('bob') expect(JSON.stringify(res.payload)).not.toContain('secret.pdf') }) it('denies an unauthenticated caller', async () => { const { env } = makeEnv() expect((await processFiles('', env, () => {}, 1_000_000)).status).toBe(401) }) }) describe('processDownload — isolation', () => { it("allows the owner's own key", async () => { const { env } = makeEnv() expect((await processDownload('sess-a', 'alice/a.pdf', env, () => {}, 1_000_000)).status).toBe( 200, ) }) it("denies person A's session on person B's key", async () => { const { env } = makeEnv() const res = await processDownload('sess-a', 'bob/secret.pdf', env, () => {}, 1_000_000) expect(res.status).toBe(403) }) it('denies a traversal escape', async () => { const { env } = makeEnv() const res = await processDownload('sess-a', 'alice/../bob/secret.pdf', env, () => {}, 1_000_000) expect(res.status).toBe(403) }) it('denies an unauthenticated caller', async () => { const { env } = makeEnv() expect((await processDownload('', 'alice/a.pdf', env, () => {}, 1_000_000)).status).toBe(401) }) }) describe('processDelete — isolation and both-sides', () => { it('removes object and manifest row together', async () => { const { env, deleted, manifest } = makeEnv() const res = await processDelete('sess-a', 'alice/a.pdf', env, () => {}, () => 1_000_000) expect(res.status).toBe(200) expect(deleted).toEqual(['alice/a.pdf']) expect(manifest.find((m) => m.objectKey === 'alice/a.pdf')).toBeUndefined() }) it("denies person A's session on person B's key, and deletes nothing", async () => { const { env, deleted, manifest } = makeEnv() const res = await processDelete('sess-a', 'bob/secret.pdf', env, () => {}, () => 1_000_000) expect(res.status).toBe(403) expect(deleted).toEqual([]) expect(manifest.find((m) => m.objectKey === 'bob/secret.pdf')).toBeDefined() }) it('denies an unauthenticated caller', async () => { const { env, deleted } = makeEnv() expect((await processDelete('', 'alice/a.pdf', env, () => {}, () => 1_000_000)).status).toBe(401) expect(deleted).toEqual([]) }) it('emits a per-step lifeline, not one bare line', async () => { const { env } = makeEnv() const lines: string[] = [] await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000) const joined = lines.join('\n') expect(joined).toContain('op=r2-delete') expect(joined).toContain('op=manifest-delete') expect(joined).toMatch(/op=complete .*result=ok objectPresent=false rowPresent=false/) }) it('reports the R2 failure instead of throwing out of the handler', async () => { // Unwrapped, a throw here escapes to a bare Pages 500 with zero // [data-portal] lines — the lifeline goes silent exactly when it is needed. const { env, manifest } = makeEnv() ;(env.BUCKET as unknown as { delete: () => Promise }).delete = () => { throw new Error('r2 unavailable') } const lines: string[] = [] const res = await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000) expect(res.status).toBe(502) expect(lines.join('\n')).toContain('op=r2-delete') expect(lines.join('\n')).toContain('result=failed') // The row must survive an object that was never deleted. expect(manifest.find((m) => m.objectKey === 'alice/a.pdf')).toBeDefined() }) it('derives result from the post-condition rather than hardcoding ok', async () => { // A delete that left the object behind must not print result=ok next to // objectPresent=true — a self-contradicting line. const { env } = makeEnv() ;(env.BUCKET as unknown as { delete: () => Promise }).delete = async () => {} const lines: string[] = [] const res = await processDelete('sess-a', 'alice/a.pdf', env, (l) => lines.push(l), () => 1_000_000) expect(res.status).toBe(500) expect(lines.join('\n')).toMatch(/op=complete .*result=incomplete objectPresent=true/) }) })