tests/channel-delete-rbac.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import { beforeEach, describe, expect, it, vi } from 'vitest'
const checkPermissionMock = vi.fn()
const closeClientMock = vi.fn()
const getPgClientMock = vi.fn()
const logPgErrorMock = vi.fn()
const queryMock = vi.fn()
const releaseMock = vi.fn()
const connectMock = vi.fn()
const pgClientMock = { connect: connectMock }
const PREVIEW_ORG = '046a36ac-e03c-4590-9257-bd6c9dba9ee8'
const TRANSFERRED_ORG = '7d456c9f-0957-4e9c-a5ca-99592dc32cf'
vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({
checkPermission: (...args: unknown[]) => checkPermissionMock(...args),
}))
vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => ({
...(await import('./helpers/pg-checkout-release-mocks.ts')),
closeClient: (...args: unknown[]) => closeClientMock(...args),
getPgClient: (...args: unknown[]) => getPgClientMock(...args),
logPgError: (...args: unknown[]) => logPgErrorMock(...args),
getDrizzleClient: () => ({}),
}))
const { jsonRows } = await import('./helpers/pg-checkout-release-mocks.ts')
const { deleteChannel } = await import('../supabase/functions/_backend/public/channel/delete.ts')
// Non-preview path: the lookup and the delete each run in their own API-key RLS transaction.
function createChannelDeleteQuery(deletedRows: unknown[] = [{ id: 42 }]) {
return async (text: string) => {
if (text.includes('DELETE FROM public.channels'))
return jsonRows(deletedRows)
if (text.includes('SELECT id FROM public.channels'))
return jsonRows([{ id: 42 }])
return { rowCount: 0, rows: [] }
}
}
function channelDeleteCalls() {
return queryMock.mock.calls.filter(([text]) => String(text).includes('DELETE FROM public.channels'))
}
function context() {
return {
get: vi.fn((key: string) => {
if (key === 'capgkey')
return 'test-apikey'
return undefined
}),
json: vi.fn((body: unknown) => Response.json(body)),
}
}
function createPreviewDeleteQuery(appOwner = PREVIEW_ORG) {
return async (text: string) => {
if (text.includes('SELECT owner_org') && text.includes('FROM public.channels') && !text.includes('FOR UPDATE')) {
return { rowCount: 1, rows: [{ owner_org: PREVIEW_ORG }] }
}
if (text.includes('FROM public.apps')) {
return { rowCount: 1, rows: [{ owner_org: appOwner }] }
}
if (text.includes('SELECT id, app_id, owner_org') && text.includes('FROM public.channels')) {
return {
rowCount: 1,
rows: [{
id: 42,
app_id: 'com.example.app',
owner_org: PREVIEW_ORG,
rbac_id: 'c535b25e-55e2-4fb4-ae8c-53f6c3a9ee7a',
version: 7,
rollout_version: null,
}],
}
}
if (text.includes('rbac_check_permission_direct')) {
return { rowCount: 1, rows: [{ allowed: true }] }
}
if (text.includes('FROM public.role_bindings')) {
return { rowCount: 1, rows: [{ id: 'binding-id' }] }
}
if (text.includes('UPDATE public.app_versions')) {
return { rowCount: 1, rows: [{ id: 7 }] }
}
if (text.includes('FROM public.app_versions')) {
return { rowCount: 1, rows: [{ id: 7, created_by_apikey_rbac_id: '45e18508-b1f6-4a85-b4c6-37ba102fbd10' }] }
}
if (text.includes('AND id <> $2')) {
return { rowCount: 0, rows: [] }
}
if (text.includes('DELETE FROM public.channels')) {
return { rowCount: 1, rows: [{ id: 42 }] }
}
return { rowCount: 0, rows: [] }
}
}
describe('channel delete RBAC guard', () => {
let c: ReturnType<typeof context>
beforeEach(() => {
vi.clearAllMocks()
queryMock.mockReset()
getPgClientMock.mockReturnValue(pgClientMock)
connectMock.mockResolvedValue({ query: queryMock, release: releaseMock })
queryMock.mockImplementation(createChannelDeleteQuery())
c = context()
checkPermissionMock.mockResolvedValue(true)
})
it('checks delete permission against the target channel', async () => {
const response = await deleteChannel(c as any, {
app_id: 'com.example.app',
channel: 'production',
}, { key: 'test-apikey' } as any)
expect(response.status).toBe(200)
expect(checkPermissionMock).toHaveBeenCalledTimes(1)
expect(checkPermissionMock).toHaveBeenCalledWith(c, 'channel.delete', {
appId: 'com.example.app',
channelId: 42,
})
expect(channelDeleteCalls()).toHaveLength(1)
expect(channelDeleteCalls()[0][1]).toEqual([42, 'com.example.app', 'production'])
// Both reads run as the API key, like the supabaseApikey client did: the context rides on BEGIN.
const contextCalls = queryMock.mock.calls.filter(([text]) => String(text).includes('request.headers'))
expect(contextCalls).toHaveLength(2)
for (const [text] of contextCalls) {
expect(text).toMatch(/^BEGIN; SELECT /)
expect(text).toContain(`set_config('request.headers', '${JSON.stringify({ capgkey: 'test-apikey' })}', true)`)
expect(text).toContain('set_config(\'role\', \'anon\', true)')
}
})
it('fails when channel deletion affects no rows', async () => {
queryMock.mockImplementation(createChannelDeleteQuery([]))
await expect(deleteChannel(c as any, {
app_id: 'com.example.app',
channel: 'production',
}, { key: 'test-apikey' } as any)).rejects.toHaveProperty('status', 400)
expect(channelDeleteCalls()).toHaveLength(1)
expect(String(channelDeleteCalls()[0][0])).toContain('RETURNING id')
})
it('does not delete the channel when channel-scoped delete is denied', async () => {
checkPermissionMock.mockResolvedValueOnce(false)
await expect(deleteChannel(c as any, {
app_id: 'com.example.app',
channel: 'production',
}, { key: 'test-apikey' } as any)).rejects.toHaveProperty('status', 400)
expect(checkPermissionMock).toHaveBeenCalledTimes(1)
expect(checkPermissionMock).toHaveBeenCalledWith(c, 'channel.delete', {
appId: 'com.example.app',
channelId: 42,
})
expect(channelDeleteCalls()).toHaveLength(0)
})
it('locks the org and app before preview channel rows', async () => {
queryMock.mockImplementation(createPreviewDeleteQuery())
const response = await deleteChannel(c as any, {
app_id: 'com.example.app',
channel: 'preview',
delete_bundle: true,
}, {
key: 'test-apikey',
user_id: 'user-test',
rbac_id: '45e18508-b1f6-4a85-b4c6-37ba102fbd10',
} as any)
expect(response.status).toBe(200)
const queryTexts = queryMock.mock.calls.map(([text]) => String(text))
const channelOwnerLookupIndex = queryTexts.findIndex(text => text.includes('SELECT owner_org') && text.includes('FROM public.channels'))
const orgLockIndex = queryTexts.findIndex(text => text.includes('lock_rbac_orgs'))
const appLockIndex = queryTexts.findIndex(text => text.includes('FROM public.apps'))
const channelLockIndex = queryTexts.findIndex(text => text.includes('SELECT id, app_id, owner_org') && text.includes('FROM public.channels'))
const bindingLockIndex = queryTexts.findIndex(text => text.includes('FROM public.role_bindings'))
const bundleLockIndex = queryTexts.findIndex(text => text.includes('pg_advisory_xact_lock'))
expect(channelOwnerLookupIndex).toBeGreaterThan(-1)
expect(queryTexts[channelOwnerLookupIndex]).not.toContain('FOR UPDATE')
expect(orgLockIndex).toBeGreaterThan(channelOwnerLookupIndex)
expect(appLockIndex).toBeGreaterThan(orgLockIndex)
expect(queryTexts[appLockIndex]).toContain('FOR UPDATE')
expect(channelLockIndex).toBeGreaterThan(appLockIndex)
expect(bindingLockIndex).toBeGreaterThan(channelLockIndex)
expect(queryTexts[bindingLockIndex]).toContain('FOR KEY SHARE')
expect(bundleLockIndex).toBeGreaterThan(bindingLockIndex)
})
it('aborts before channel locking when the app moves organizations', async () => {
queryMock.mockImplementation(createPreviewDeleteQuery(TRANSFERRED_ORG))
await expect(deleteChannel(c as any, {
app_id: 'com.example.app',
channel: 'preview',
delete_bundle: true,
}, {
key: 'test-apikey',
user_id: 'user-test',
rbac_id: '45e18508-b1f6-4a85-b4c6-37ba102fbd10',
} as any)).rejects.toHaveProperty('status', 400)
const queryTexts = queryMock.mock.calls.map(([text]) => String(text))
expect(queryTexts.some(text => text.includes('SELECT id, app_id, owner_org') && text.includes('FROM public.channels'))).toBe(false)
expect(queryTexts.some(text => text.includes('FROM public.app_versions'))).toBe(false)
expect(queryTexts.some(text => text.includes('DELETE FROM public.channels'))).toBe(false)
expect(checkPermissionMock).not.toHaveBeenCalled()
expect(queryMock).toHaveBeenCalledWith('ROLLBACK')
})
})