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 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') }) })