tests/cli-channel.test.ts
Rama main · réplica de lectura del repositorio original.
import type { Database } from '../src/types/database.types'
import { randomUUID } from 'node:crypto'
import { createClient } from '@supabase/supabase-js'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getCanonicalAppVersionR2Path } from '../supabase/functions/_backend/utils/app_version_r2_path.ts'
import { createTestSDK } from './cli-sdk-utils'
import { BASE_URL, createAppVersions, createDirectApiKeyWithBindings, createIsolatedSeedAppOptions, getSupabaseClient, resetAndSeedAppData, resetAppData, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, USER_ID } from './test-utils'
const seedOptions = createIsolatedSeedAppOptions()
// Helper to generate unique channel names for concurrent tests
const generateChannelName = () => `test-channel-${randomUUID().slice(0, 8)}`
// Cache version ID per app to avoid repeated queries
const versionCache = new Map<string, number>()
// Helper to create channel directly in database for faster test setup
async function createChannel(channelName: string, appId: string) {
let versionId = versionCache.get(appId)
if (!versionId) {
// Only query once per app
const { data: versionData } = await getSupabaseClient()
.from('app_versions')
.select('id')
.eq('app_id', appId)
.limit(1)
.single()
versionId = versionData?.id || 1
versionCache.set(appId, versionId)
}
const { error } = await getSupabaseClient()
.from('channels')
.insert({
name: channelName,
app_id: appId,
version: versionId,
owner_org: seedOptions.orgId,
created_by: USER_ID,
public: false,
disable_auto_update_under_native: true,
disable_auto_update: 'major' as const,
allow_device_self_set: false,
allow_emulator: false,
allow_device: false,
allow_dev: false,
allow_prod: false,
ios: false,
android: false,
})
if (error)
throw error
}
describe('tests CLI channel commands', () => {
const id = randomUUID()
const APPNAME = `com.cli_channel_${id}`
const invalidAppName = `invalid-app-${randomUUID().slice(0, 8)}`
beforeAll(async () => {
await resetAndSeedAppData(APPNAME, seedOptions)
})
afterAll(async () => {
await resetAppData(APPNAME)
})
describe.concurrent('channel creation', () => {
it('should create a channel', async () => {
const channelName = generateChannelName()
const result = await createTestSDK().addChannel({ channelId: channelName, appId: APPNAME })
expect(result.success).toBe(true)
// Verify in database
const { data, error } = await getSupabaseClient()
.from('channels')
.select('*')
.eq('name', channelName)
.eq('app_id', APPNAME)
expect(error).toBeNull()
expect(data).toHaveLength(1)
})
it.concurrent('should fail to create a channel with invalid app ID', async () => {
const testChannelName = generateChannelName()
const result = await createTestSDK().addChannel({ channelId: testChannelName, appId: invalidAppName })
expect(result.success).toBe(false)
expect(result.error).toContain('does not exist')
// Verify channel wasn't created
const { data } = await getSupabaseClient()
.from('channels')
.select('*')
.eq('name', testChannelName)
.eq('app_id', invalidAppName)
expect(data).toHaveLength(0)
})
it.concurrent('should fail to create a duplicate channel', async () => {
const channelName = generateChannelName()
// Create the channel first
await createChannel(channelName, APPNAME)
const result = await createTestSDK().addChannel({ channelId: channelName, appId: APPNAME })
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
})
describe.concurrent('channel listing', () => {
it.concurrent('should list channels', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const result = await createTestSDK().listChannels(APPNAME)
expect(result.success).toBe(true)
expect(result.data).toBeDefined()
const channel = result.data!.find(c => c.name === channelName)
expect(channel).toBeDefined()
})
it.concurrent('should show empty list for invalid app ID', async () => {
const testInvalidApp = `invalid-app-${randomUUID().slice(0, 8)}`
const result = await createTestSDK().listChannels(testInvalidApp)
// SDK will fail for non-existent app
expect(result.success).toBe(false)
// Optional: verify no channels exist for invalid app
const { data } = await getSupabaseClient()
.from('channels')
.select('*')
.eq('app_id', testInvalidApp)
expect(data).toHaveLength(0)
})
})
describe.concurrent('channel bundle operations', () => {
it.concurrent('should set channel bundle', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const bundle = `1.0.${Math.floor(Math.random() * 10000)}`
await getSupabaseClient()
.from('app_versions')
.insert({
app_id: APPNAME,
name: bundle,
owner_org: seedOptions.orgId,
user_id: USER_ID,
storage_provider: 'r2-direct',
})
.throwOnError()
const result = await createTestSDK().updateChannel({ channelId: channelName, appId: APPNAME, bundle })
expect(result.success).toBe(true)
// Verify in database
const { data, error } = await getSupabaseClient()
.from('channels')
.select('id, version:app_versions!channels_version_fkey(id, name)')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
.throwOnError()
expect(error).toBeNull()
expect(data?.version?.name).toBe(bundle)
})
it.concurrent('should get current bundle with a channel-scoped reader key', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const capgo = getSupabaseClient()
const { data: app, error: appError } = await capgo
.from('apps')
.select('id')
.eq('app_id', APPNAME)
.single()
expect(appError).toBeNull()
expect(app?.id).toBeTruthy()
const { data: channel, error: channelError } = await capgo
.from('channels')
.select('id, rbac_id, version ( name )')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
expect(channelError).toBeNull()
expect(channel?.rbac_id).toBeTruthy()
expect(channel?.version?.name).toBeTruthy()
const { data: channelReaderRole, error: roleError } = await capgo
.from('roles')
.select('id')
.eq('name', 'channel_reader')
.eq('scope_type', 'channel')
.single()
expect(roleError).toBeNull()
expect(channelReaderRole?.id).toBeTruthy()
const key = `channel-current-bundle-${randomUUID()}`
const apiKey = await createDirectApiKeyWithBindings({
key,
name: `Channel current bundle reader ${channelName}`,
orgId: seedOptions.orgId,
roleName: 'org_member',
})
const resolvedKey = apiKey.key ?? key
try {
const { error: bindingError } = await capgo
.from('role_bindings')
.insert({
principal_type: 'apikey',
principal_id: apiKey.rbac_id,
role_id: channelReaderRole!.id,
scope_type: 'channel',
org_id: seedOptions.orgId,
app_id: app!.id,
channel_id: channel!.rbac_id,
granted_by: apiKey.user_id,
reason: 'CLI current bundle channel-reader regression test',
is_direct: true,
})
expect(bindingError).toBeNull()
const { data: directAllowed, error: directError } = await capgo.rpc('rbac_check_permission_direct' as any, {
p_permission_key: 'channel.read',
p_user_id: apiKey.user_id,
p_org_id: seedOptions.orgId,
p_app_id: APPNAME,
p_channel_id: channel!.id,
p_apikey: resolvedKey,
})
expect(directError).toBeNull()
expect(directAllowed).toBe(true)
const result = await createTestSDK(resolvedKey).getCurrentBundle(APPNAME, channelName)
expect(result.success, result.error).toBe(true)
expect(result.data).toBe(channel?.version?.name)
}
finally {
await capgo.from('role_bindings').delete().eq('principal_id', apiKey.rbac_id)
await capgo.from('apikeys').delete().eq('id', apiKey.id)
}
})
it.concurrent('should set channel bundle with a channel-scoped admin key', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const bundle = `1.0.${Math.floor(Math.random() * 10000)}`
await getSupabaseClient()
.from('app_versions')
.insert({
app_id: APPNAME,
name: bundle,
owner_org: seedOptions.orgId,
user_id: USER_ID,
storage_provider: 'r2-direct',
})
.throwOnError()
const capgo = getSupabaseClient()
const { data: app, error: appError } = await capgo
.from('apps')
.select('id')
.eq('app_id', APPNAME)
.single()
expect(appError).toBeNull()
expect(app?.id).toBeTruthy()
const { data: channel, error: channelError } = await capgo
.from('channels')
.select('id, rbac_id')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
expect(channelError).toBeNull()
expect(channel?.rbac_id).toBeTruthy()
const { data: channelAdminRole, error: roleError } = await capgo
.from('roles')
.select('id')
.eq('name', 'channel_admin')
.eq('scope_type', 'channel')
.single()
expect(roleError).toBeNull()
expect(channelAdminRole?.id).toBeTruthy()
const key = `channel-update-${randomUUID()}`
const apiKey = await createDirectApiKeyWithBindings({
key,
name: `Channel update admin ${channelName}`,
orgId: seedOptions.orgId,
roleName: 'org_member',
})
const resolvedKey = apiKey.key ?? key
try {
const { error: bindingError } = await capgo
.from('role_bindings')
.insert({
principal_type: 'apikey',
principal_id: apiKey.rbac_id,
role_id: channelAdminRole!.id,
scope_type: 'channel',
org_id: seedOptions.orgId,
app_id: app!.id,
channel_id: channel!.rbac_id,
granted_by: apiKey.user_id,
reason: 'CLI channel set channel-admin regression test',
is_direct: true,
})
expect(bindingError).toBeNull()
const { data: directAllowed, error: directError } = await capgo.rpc('rbac_check_permission_direct' as any, {
p_permission_key: 'channel.update_settings',
p_user_id: apiKey.user_id,
p_org_id: seedOptions.orgId,
p_app_id: APPNAME,
p_channel_id: channel!.id,
p_apikey: resolvedKey,
})
expect(directError).toBeNull()
expect(directAllowed).toBe(true)
const result = await createTestSDK(resolvedKey).updateChannel({ channelId: channelName, appId: APPNAME, bundle })
expect(result.success, result.error).toBe(true)
}
finally {
await capgo.from('role_bindings').delete().eq('principal_id', apiKey.rbac_id)
await capgo.from('apikeys').delete().eq('id', apiKey.id)
}
})
it.concurrent('should set channel bundle with app developer promotion permission only', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const bundle = `1.0.${Math.floor(Math.random() * 10000)}`
await getSupabaseClient()
.from('app_versions')
.insert({
app_id: APPNAME,
name: bundle,
owner_org: seedOptions.orgId,
user_id: USER_ID,
storage_provider: 'r2-direct',
})
.throwOnError()
const apiKey = await createDirectApiKeyWithBindings({
key: `channel-promote-${randomUUID()}`,
name: `Channel promote developer ${channelName}`,
orgId: seedOptions.orgId,
roleName: 'org_member',
appId: APPNAME,
appRoleName: 'app_developer',
})
const resolvedKey = apiKey.key ?? ''
try {
const capgo = getSupabaseClient()
const { data: channel, error: channelError } = await capgo
.from('channels')
.select('id')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
expect(channelError).toBeNull()
expect(channel?.id).toBeTruthy()
const { data: canPromote, error: promoteError } = await capgo.rpc('rbac_check_permission_direct' as any, {
p_permission_key: 'channel.promote_bundle',
p_user_id: apiKey.user_id,
p_org_id: seedOptions.orgId,
p_app_id: APPNAME,
p_channel_id: channel!.id,
p_apikey: resolvedKey,
})
expect(promoteError).toBeNull()
expect(canPromote).toBe(true)
const { data: canUpdateSettings, error: settingsError } = await capgo.rpc('rbac_check_permission_direct' as any, {
p_permission_key: 'channel.update_settings',
p_user_id: apiKey.user_id,
p_org_id: seedOptions.orgId,
p_app_id: APPNAME,
p_channel_id: channel!.id,
p_apikey: resolvedKey,
})
expect(settingsError).toBeNull()
expect(canUpdateSettings).toBe(false)
const result = await createTestSDK(resolvedKey).updateChannel({ channelId: channelName, appId: APPNAME, bundle })
expect(result.success, result.error).toBe(true)
}
finally {
await getSupabaseClient().from('role_bindings').delete().eq('principal_id', apiKey.rbac_id)
await getSupabaseClient().from('apikeys').delete().eq('id', apiKey.id)
}
})
it.concurrent('should fail to set bundle for invalid channel name', async () => {
const bundle = '1.0.0'
const testInvalidChannel = generateChannelName()
const result = await createTestSDK().updateChannel({ channelId: testInvalidChannel, appId: APPNAME, bundle })
expect(result.success).toBe(false)
expect(result.error).toContain('Cannot find channel')
})
it.concurrent('should fail to set invalid bundle version', async () => {
const invalidBundle = 'not-a-version'
const testChannelName = generateChannelName()
await createChannel(testChannelName, APPNAME)
const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: invalidBundle })
expect(result.success).toBe(false)
expect(result.error).toContain('Cannot find version')
})
})
describe.concurrent('channel promote operations', () => {
it.concurrent('should promote the bundle of one channel to another channel', async () => {
const fromChannel = generateChannelName()
const toChannel = generateChannelName()
await createChannel(fromChannel, APPNAME)
await createChannel(toChannel, APPNAME)
const bundle = `1.1.${Math.floor(Math.random() * 10000)}`
const { data: version } = await getSupabaseClient()
.from('app_versions')
.insert({
app_id: APPNAME,
name: bundle,
owner_org: seedOptions.orgId,
user_id: USER_ID,
storage_provider: 'r2-direct',
})
.select('id')
.single()
.throwOnError()
await getSupabaseClient()
.from('channels')
.update({ version: version!.id })
.eq('name', fromChannel)
.eq('app_id', APPNAME)
.throwOnError()
const result = await createTestSDK().promoteChannel({ appId: APPNAME, fromChannel, toChannel })
expect(result.success).toBe(true)
expect(result.data).toEqual({ bundle, fromChannel, toChannel })
const { data } = await getSupabaseClient()
.from('channels')
.select('name, version')
.eq('app_id', APPNAME)
.in('name', [fromChannel, toChannel])
.throwOnError()
expect(data?.find(row => row.name === toChannel)?.version).toBe(version!.id)
expect(data?.find(row => row.name === fromChannel)?.version).toBe(version!.id)
})
it.concurrent('should refuse to promote a channel to itself', async () => {
const channelName = generateChannelName()
const result = await createTestSDK().promoteChannel({ appId: APPNAME, fromChannel: channelName, toChannel: channelName })
expect(result.success).toBe(false)
expect(result.error).toContain('must be different')
})
it.concurrent('should fail to promote from a missing channel', async () => {
const toChannel = generateChannelName()
await createChannel(toChannel, APPNAME)
const { data: before } = await getSupabaseClient()
.from('channels')
.select('version')
.eq('name', toChannel)
.eq('app_id', APPNAME)
.single()
.throwOnError()
const result = await createTestSDK().promoteChannel({ appId: APPNAME, fromChannel: generateChannelName(), toChannel })
expect(result.success).toBe(false)
expect(result.error).toContain('Channel not found for app')
const { data: after } = await getSupabaseClient()
.from('channels')
.select('version')
.eq('name', toChannel)
.eq('app_id', APPNAME)
.single()
.throwOnError()
expect(after?.version).toBe(before?.version)
})
it.concurrent('should fail to promote from a channel without a bundle', async () => {
const fromChannel = generateChannelName()
const toChannel = generateChannelName()
await createChannel(fromChannel, APPNAME)
await createChannel(toChannel, APPNAME)
await getSupabaseClient()
.from('channels')
.update({ version: null })
.eq('name', fromChannel)
.eq('app_id', APPNAME)
.throwOnError()
const { data: before } = await getSupabaseClient()
.from('channels')
.select('version')
.eq('name', toChannel)
.eq('app_id', APPNAME)
.single()
.throwOnError()
const result = await createTestSDK().promoteChannel({ appId: APPNAME, fromChannel, toChannel })
expect(result.success).toBe(false)
expect(result.error).toContain('Channel does not have a bundle linked')
const { data: after } = await getSupabaseClient()
.from('channels')
.select('version')
.eq('name', toChannel)
.eq('app_id', APPNAME)
.single()
.throwOnError()
expect(after?.version).toBe(before?.version)
})
})
describe.concurrent('channel setting updates', () => {
it.concurrent.each([
{ name: 'state default makes the channel public', options: { state: 'default' }, expected: { public: true } },
{ name: 'state normal keeps the channel private', options: { state: 'normal' }, expected: { public: false } },
{ name: 'downgrade policy', options: { downgrade: true }, expected: { disable_auto_update_under_native: false } },
{ name: 'ios platform', options: { ios: true }, expected: { ios: true } },
{ name: 'android platform', options: { android: true }, expected: { android: true } },
{ name: 'both platforms', options: { ios: true, android: true }, expected: { ios: true, android: true } },
{ name: 'self-assign', options: { selfAssign: true }, expected: { allow_device_self_set: true } },
{ name: 'disable auto update strategy', options: { disableAutoUpdate: 'minor' }, expected: { disable_auto_update: 'minor' } },
{ name: 'dev environment', options: { dev: true }, expected: { allow_dev: true } },
{ name: 'emulator environment', options: { emulator: true }, expected: { allow_emulator: true } },
{ name: 'multiple properties in a single command', options: { state: 'default', downgrade: true, ios: true }, expected: { public: true, disable_auto_update_under_native: false, ios: true } },
])('should set $name', async ({ options, expected }) => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const result = await createTestSDK().updateChannel({ channelId: channelName, appId: APPNAME, ...options })
expect(result.success).toBe(true)
const { data } = await getSupabaseClient()
.from('channels')
.select('*')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
.throwOnError()
expect(data).toMatchObject(expected)
})
it.concurrent('should fail to set invalid state', async () => {
const invalidState = 'invalid-state'
const testChannelName = generateChannelName()
await createChannel(testChannelName, APPNAME)
const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: undefined, ...{ state: invalidState } })
expect(result.success).toBe(false)
expect(result.error).toContain('Unknown state')
})
})
it.concurrent('should set channel update package', async () => {
const testChannelName = generateChannelName()
await createChannel(testChannelName, APPNAME)
const withDeltaName = `1.0.cli-delta-set-${randomUUID().slice(0, 8)}`
const withDelta = await createAppVersions(withDeltaName, APPNAME, {
checksum: 'cli-delta-set',
storage_provider: 'r2',
r2_path: getCanonicalAppVersionR2Path(seedOptions.orgId, APPNAME, withDeltaName),
})
await getSupabaseClient()
.from('manifest')
.insert({
app_version_id: withDelta.id,
file_name: `cli-delta-${testChannelName}.js`,
s3_path: `/cli-delta-${testChannelName}.js`,
file_hash: 'cli-delta',
file_size: 8,
})
.throwOnError()
await getSupabaseClient()
.from('channels')
.update({ version: withDelta.id })
.eq('app_id', APPNAME)
.eq('name', testChannelName)
.throwOnError()
const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: undefined, updatePackage: 'delta' })
expect(result.success).toBe(true)
const { data, error } = await getSupabaseClient()
.from('channels')
.select('*')
.eq('name', testChannelName)
.eq('app_id', APPNAME)
.single()
.throwOnError()
expect(error).toBeNull()
expect(data?.update_package).toBe('delta')
})
it.concurrent('should refuse delta-only when the linked bundle has no delta files', async () => {
const testChannelName = generateChannelName()
await createChannel(testChannelName, APPNAME)
const zipOnlyName = `1.0.cli-zip-${randomUUID().slice(0, 8)}`
const zipOnly = await createAppVersions(zipOnlyName, APPNAME, {
checksum: 'cli-zip-only',
storage_provider: 'r2',
r2_path: getCanonicalAppVersionR2Path(seedOptions.orgId, APPNAME, zipOnlyName),
})
await getSupabaseClient()
.from('channels')
.update({ version: zipOnly.id, update_package: 'all' })
.eq('app_id', APPNAME)
.eq('name', testChannelName)
.throwOnError()
const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: undefined, updatePackage: 'delta' })
expect(result.success).toBe(false)
expect(result.error).toContain('CHANNEL_DELTA_REQUIRED')
expect(result.error).toContain('no delta files')
})
it.concurrent('should refuse assigning a delta-only bundle to a zip-only channel', async () => {
const testChannelName = generateChannelName()
await createChannel(testChannelName, APPNAME)
const { data: zipVersion } = await getSupabaseClient()
.from('app_versions')
.select('id, name')
.eq('app_id', APPNAME)
.eq('name', '1.0.0')
.single()
.throwOnError()
await getSupabaseClient()
.from('channels')
.update({ version: zipVersion.id, update_package: 'zip' })
.eq('app_id', APPNAME)
.eq('name', testChannelName)
.throwOnError()
const deltaOnly = `1.0.cli-delta-${randomUUID().slice(0, 8)}`
const version = await createAppVersions(deltaOnly, APPNAME, {
checksum: 'cli-delta-only',
storage_provider: 'r2-direct',
r2_path: null,
})
await getSupabaseClient()
.from('manifest')
.insert({
app_version_id: version.id,
file_name: `${deltaOnly}.js`,
s3_path: `/${deltaOnly}.js`,
file_hash: 'cli-delta-only',
file_size: 8,
})
.throwOnError()
const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: deltaOnly })
expect(result.success).toBe(false)
expect(result.error).toContain('CHANNEL_ZIP_REQUIRED')
expect(result.error).toContain('no zip')
})
describe.concurrent('channel info operations', () => {
it.concurrent('should get current bundle of channel', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const sdk = createTestSDK()
const result = await sdk.getCurrentBundle(APPNAME, channelName)
expect(result.success).toBe(true)
expect(result.data).toBeDefined()
// Verify in database
const { data, error } = await getSupabaseClient()
.from('channels')
.select('version:app_versions!channels_version_fkey(name)')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
.throwOnError()
expect(error).toBeNull()
expect(result.data).toBe(data?.version?.name)
})
it.concurrent('should fail to get bundle for non-existent channel', async () => {
const testInvalidChannel = generateChannelName()
const sdk = createTestSDK()
const result = await sdk.getCurrentBundle(APPNAME, testInvalidChannel)
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
})
describe.concurrent('channel deletion', () => {
it.concurrent('should delete a channel', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const result = await createTestSDK().deleteChannel(channelName, APPNAME, false)
expect(result.success).toBe(true)
// Verify in database
const { data, error } = await getSupabaseClient()
.from('channels')
.select('*')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
expect(error).toBeDefined()
expect(data).toBeNull()
})
it.concurrent('should delete channel and associated channel_devices', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
// Get the channel ID
const { data: channelData } = await getSupabaseClient()
.from('channels')
.select('id')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
.throwOnError()
const channelId = channelData!.id
// Create some channel_devices
const deviceIds = [randomUUID(), randomUUID(), randomUUID()]
for (const deviceId of deviceIds) {
await getSupabaseClient()
.from('channel_devices')
.insert({
channel_id: channelId,
device_id: deviceId,
app_id: APPNAME,
owner_org: seedOptions.orgId,
})
.throwOnError()
}
// Verify channel_devices were created
const { data: devicesBefore } = await getSupabaseClient()
.from('channel_devices')
.select('*')
.eq('channel_id', channelId)
expect(devicesBefore).toHaveLength(3)
// Delete the channel
const result = await createTestSDK().deleteChannel(channelName, APPNAME, false)
expect(result.success).toBe(true)
// Verify channel is deleted
const { data: channelAfter, error: channelError } = await getSupabaseClient()
.from('channels')
.select('*')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
expect(channelError).toBeDefined()
expect(channelAfter).toBeNull()
// Verify channel_devices are also deleted
const { data: devicesAfter } = await getSupabaseClient()
.from('channel_devices')
.select('*')
.eq('channel_id', channelId)
expect(devicesAfter).toHaveLength(0)
})
it.concurrent('should delete channel with a channel-scoped admin key', async () => {
const channelName = generateChannelName()
await createChannel(channelName, APPNAME)
const capgo = getSupabaseClient()
const { data: app, error: appError } = await capgo
.from('apps')
.select('id')
.eq('app_id', APPNAME)
.single()
expect(appError).toBeNull()
expect(app?.id).toBeTruthy()
const { data: channel, error: channelError } = await capgo
.from('channels')
.select('id, rbac_id')
.eq('name', channelName)
.eq('app_id', APPNAME)
.single()
expect(channelError).toBeNull()
expect(channel?.rbac_id).toBeTruthy()
const deviceId = randomUUID()
await capgo
.from('channel_devices')
.insert({
channel_id: channel!.id,
device_id: deviceId,
app_id: APPNAME,
owner_org: seedOptions.orgId,
})
.throwOnError()
const { data: channelAdminRole, error: roleError } = await capgo
.from('roles')
.select('id')
.eq('name', 'channel_admin')
.eq('scope_type', 'channel')
.single()
expect(roleError).toBeNull()
expect(channelAdminRole?.id).toBeTruthy()
const key = `channel-delete-${randomUUID()}`
const apiKey = await createDirectApiKeyWithBindings({
key,
name: `Channel delete admin ${channelName}`,
orgId: seedOptions.orgId,
roleName: 'org_member',
})
const resolvedKey = apiKey.key ?? key
try {
const { error: bindingError } = await capgo
.from('role_bindings')
.insert({
principal_type: 'apikey',
principal_id: apiKey.rbac_id,
role_id: channelAdminRole!.id,
scope_type: 'channel',
org_id: seedOptions.orgId,
app_id: app!.id,
channel_id: channel!.rbac_id,
granted_by: apiKey.user_id,
reason: 'CLI channel delete channel-admin regression test',
is_direct: true,
})
expect(bindingError).toBeNull()
const { data: directAllowed, error: directError } = await capgo.rpc('rbac_check_permission_direct' as any, {
p_permission_key: 'channel.delete',
p_user_id: apiKey.user_id,
p_org_id: seedOptions.orgId,
p_app_id: APPNAME,
p_channel_id: channel!.id,
p_apikey: resolvedKey,
})
expect(directError).toBeNull()
expect(directAllowed).toBe(true)
const result = await createTestSDK(resolvedKey).deleteChannel(channelName, APPNAME, false)
expect(result.success, result.error).toBe(true)
const { data: channelAfter, error: deletedChannelError } = await capgo
.from('channels')
.select('id')
.eq('id', channel!.id)
.single()
expect(deletedChannelError).toBeDefined()
expect(channelAfter).toBeNull()
const { data: devicesAfter } = await capgo
.from('channel_devices')
.select('id')
.eq('channel_id', channel!.id)
expect(devicesAfter).toHaveLength(0)
}
finally {
await capgo.from('role_bindings').delete().eq('principal_id', apiKey.rbac_id)
await capgo.from('apikeys').delete().eq('id', apiKey.id)
}
})
it.concurrent('should fail to delete non-existent channel', async () => {
const testInvalidChannel = generateChannelName()
const result = await createTestSDK().deleteChannel(testInvalidChannel, APPNAME, false)
expect(result.success).toBe(false)
expect(result.error).toContain('not found')
})
it.concurrent('should fail to delete channel with invalid app ID', async () => {
const testChannelName = generateChannelName()
const testInvalidApp = `invalid-app-${randomUUID().slice(0, 8)}`
const result = await createTestSDK().deleteChannel(testChannelName, testInvalidApp, false)
expect(result.success).toBe(false)
expect(result.error).toContain('does not exist')
})
})
describe('channel-admin HTTP and PostgREST isolation', () => {
it('allows settings-only POST and DELETE for the target channel while RLS hides its sibling', async () => {
const targetChannelName = generateChannelName()
const siblingChannelName = generateChannelName()
await createChannel(targetChannelName, APPNAME)
await createChannel(siblingChannelName, APPNAME)
const capgo = getSupabaseClient()
const [{ data: app, error: appError }, { data: target, error: targetError }, { data: sibling, error: siblingError }, { data: channelAdminRole, error: roleError }] = await Promise.all([
capgo.from('apps').select('id').eq('app_id', APPNAME).single(),
capgo.from('channels').select('id, rbac_id, version').eq('app_id', APPNAME).eq('name', targetChannelName).single(),
capgo.from('channels').select('id, rbac_id').eq('app_id', APPNAME).eq('name', siblingChannelName).single(),
capgo.from('roles').select('id').eq('name', 'channel_admin').eq('scope_type', 'channel').single(),
])
expect(appError).toBeNull()
expect(targetError).toBeNull()
expect(siblingError).toBeNull()
expect(roleError).toBeNull()
expect(app?.id).toBeTruthy()
expect(target?.rbac_id).toBeTruthy()
expect(channelAdminRole?.id).toBeTruthy()
const key = `channel-http-admin-${randomUUID()}`
const apiKey = await createDirectApiKeyWithBindings({
key,
name: `Channel HTTP admin ${targetChannelName}`,
orgId: seedOptions.orgId,
roleName: 'org_billing_admin',
})
const resolvedKey = apiKey.key ?? key
try {
const { error: bindingError } = await capgo
.from('role_bindings')
.insert({
principal_type: 'apikey',
principal_id: apiKey.rbac_id,
role_id: channelAdminRole!.id,
scope_type: 'channel',
org_id: seedOptions.orgId,
app_id: app!.id,
channel_id: target!.rbac_id,
granted_by: apiKey.user_id,
reason: 'Channel HTTP and RLS isolation regression test',
is_direct: true,
})
expect(bindingError).toBeNull()
const scopedSupabase = createClient<Database>(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, {
auth: { persistSession: false },
global: { headers: { capgkey: resolvedKey } },
})
const { data: scopedApps, error: scopedAppsError } = await scopedSupabase
.from('apps')
.select('app_id')
.eq('app_id', APPNAME)
expect(scopedAppsError).toBeNull()
expect(scopedApps).toHaveLength(0)
const { data: scopedChannels, error: scopedChannelsError } = await scopedSupabase
.from('channels')
.select('id, name')
.eq('app_id', APPNAME)
.in('name', [targetChannelName, siblingChannelName])
expect(scopedChannelsError).toBeNull()
expect(scopedChannels).toEqual([{ id: target!.id, name: targetChannelName }])
// Channel-scoped admins may edit settings, but promoting to public
// requires app.update_settings and must be denied here.
const { error: targetPublicUpdateError } = await scopedSupabase
.from('channels')
.update({ public: true })
.eq('id', target!.id)
expect(targetPublicUpdateError?.code).toBe('42501')
const { error: targetUpdateError } = await scopedSupabase
.from('channels')
.update({ allow_emulator: true })
.eq('id', target!.id)
expect(targetUpdateError).toBeNull()
const { error: siblingUpdateError } = await scopedSupabase
.from('channels')
.update({ allow_emulator: true })
.eq('id', sibling!.id)
expect(siblingUpdateError).toBeNull()
const { data: siblingAfterDirectUpdate, error: siblingAfterDirectUpdateError } = await capgo
.from('channels')
.select('allow_emulator')
.eq('id', sibling!.id)
.single()
expect(siblingAfterDirectUpdateError).toBeNull()
expect(siblingAfterDirectUpdate?.allow_emulator).toBe(false)
const { data: targetAfterDirectUpdate, error: targetAfterDirectUpdateError } = await capgo
.from('channels')
.select('public, allow_emulator')
.eq('id', target!.id)
.single()
expect(targetAfterDirectUpdateError).toBeNull()
expect(targetAfterDirectUpdate).toEqual({ public: false, allow_emulator: true })
const postResponse = await fetch(`${BASE_URL}/channel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'capgkey': resolvedKey,
},
body: JSON.stringify({
app_id: APPNAME,
channel: targetChannelName,
public: false,
}),
})
expect(postResponse.status).toBe(200)
const { data: targetAfterPost, error: targetAfterPostError } = await capgo
.from('channels')
.select('version, public')
.eq('id', target!.id)
.single()
expect(targetAfterPostError).toBeNull()
expect(targetAfterPost).toEqual({ version: target!.version, public: false })
const deleteResponse = await fetch(`${BASE_URL}/channel?app_id=${encodeURIComponent(APPNAME)}&channel=${encodeURIComponent(targetChannelName)}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'capgkey': resolvedKey,
},
})
expect(deleteResponse.status).toBe(200)
const { data: targetAfterDelete, error: targetAfterDeleteError } = await capgo
.from('channels')
.select('id')
.eq('id', target!.id)
.maybeSingle()
expect(targetAfterDeleteError).toBeNull()
expect(targetAfterDelete).toBeNull()
}
finally {
await capgo.from('role_bindings').delete().eq('principal_id', apiKey.rbac_id)
await capgo.from('apikeys').delete().eq('id', apiKey.id)
}
})
})
})