Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions supabase/functions/_backend/files/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,23 @@ async function assertReadableAppScopedAttachment(c: Context, fileId: unknown): P
if (!app || app.owner_org !== scopedPath.owner_org) {
quickError(404, 'not_found', 'Not found')
}

const deletedBundlePath = await pgClient.query<{ id: number }>(
`
SELECT id
FROM public.app_versions
WHERE owner_org = $1
AND app_id = $2
AND r2_path = $3
AND COALESCE(deleted, false) = true
LIMIT 1
`,
[scopedPath.owner_org, scopedPath.app_id, fileId],
)

if (deletedBundlePath.rows.length > 0) {
quickError(404, 'not_found', 'Not found')
}
Comment thread
slashdevcorpse marked this conversation as resolved.
}
finally {
await closeClient(c, pgClient)
Expand Down
47 changes: 46 additions & 1 deletion tests/files-app-read-guard.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const getAppByAppIdPgMock = vi.fn()
const getPgClientMock = vi.fn(() => ({}))
const pgClientMock = {
query: vi.fn(),
}
const getPgClientMock = vi.fn(() => pgClientMock)

vi.mock('hono/adapter', async (importOriginal) => {
const actual = await importOriginal<typeof import('hono/adapter')>()
Expand Down Expand Up @@ -32,6 +35,7 @@ describe('files app-scoped read guard', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
pgClientMock.query.mockResolvedValue({ rows: [] })
})

it('returns 404 for deleted app-scoped files before serving cached content', async () => {
Expand Down Expand Up @@ -103,4 +107,45 @@ describe('files app-scoped read guard', () => {
expect(bucketPut).not.toHaveBeenCalled()
expect(getAppByAppIdPgMock).not.toHaveBeenCalled()
})

it('returns 404 for soft-deleted bundle paths before serving cached content', async () => {
getAppByAppIdPgMock.mockResolvedValue({ app_id: 'test-app', owner_org: 'test-org' })
pgClientMock.query.mockResolvedValue({ rows: [{ id: 123 }] })

const bucketPut = vi.fn()
globalThis.caches = {
default: {
match: async () => new Response('cached deleted bundle bytes', {
headers: {
'content-type': 'application/zip',
},
}),
put: async () => { },
},
} as any

const { app: files } = await import('../supabase/functions/_backend/files/files.ts')
const { createAllCatch, createHono } = await import('../supabase/functions/_backend/utils/hono.ts')
const { version } = await import('../supabase/functions/_backend/utils/version.ts')

const appGlobal = createHono('files', version)
appGlobal.route('/', files)
createAllCatch(appGlobal, 'files')

const filePath = 'orgs/test-org/apps/test-app/1.0.0.zip'
const response = await appGlobal.fetch(
new Request(`http://localhost/read/attachments/${filePath}`),
{
ATTACHMENT_BUCKET: { put: bucketPut },
},
{ waitUntil: () => { } } as any,
)

expect(response.status).toBe(404)
expect(pgClientMock.query).toHaveBeenCalledWith(
expect.stringContaining('FROM public.app_versions'),
['test-org', 'test-app', filePath],
)
expect(bucketPut).not.toHaveBeenCalled()
})
})
Loading