import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { BASE_URL, createAppVersions, createDirectApiKeyWithBindings, fetchBundle, getSupabaseClient, headers, ORG_ID, resetAndSeedAppData, resetAppData, resetAppDataStats, USER_ID } from './test-utils.ts' const id = randomUUID() const APPNAME = `com.app.b.${id}` const RBAC_APPNAME = `com.app.b.rbac.${id}` const RBAC_ORG_ID = randomUUID() async function putBundleToChannel(body: { app_id: string, version_id: number, channel_id: number }): Promise { return fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers, body: JSON.stringify(body), }) } beforeAll(async () => { await resetAndSeedAppData(APPNAME) }) afterAll(async () => { await resetAppData(APPNAME) await resetAppDataStats(APPNAME) }) describe('[GET] /bundle operations', () => { it('valid app_id', async () => { const { response, data } = await fetchBundle(APPNAME) expect(response.status).toBe(200) expect(Array.isArray(data)).toBe(true) }) it('invalid app_id', async () => { const { response } = await fetchBundle('invalid_app') expect(response.status).toBe(400) }) }) describe('[POST] /bundle/metadata operations', () => { let versionId: number beforeAll(async () => { // Create a test version to update const version = await createAppVersions('1.0.0-test-metadata', APPNAME) versionId = version.id }) it('should update bundle metadata successfully', async () => { const response = await fetch(`${BASE_URL}/bundle/metadata`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, version_id: versionId, link: 'https://example.com/docs', comment: 'Test bundle comment', }), }) const data = await response.json() as { status: string } expect(response.status).toBe(200) expect(data.status).toBe('ok') // Verify the data was updated in the database const supabase = getSupabaseClient() const { data: version, error } = await supabase .from('app_versions') .select('*') .eq('id', versionId) .eq('app_id', APPNAME) .single() expect(error).toBeNull() expect(version).toBeTruthy() // Type assertion to access the new fields expect((version as any).link).toBe('https://example.com/docs') expect((version as any).comment).toBe('Test bundle comment') }) it('should handle missing required fields', async () => { const response = await fetch(`${BASE_URL}/bundle/metadata`, { method: 'POST', headers, body: JSON.stringify({ // Missing app_id version_id: versionId, link: 'https://example.com', }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('missing_required_fields') }) it('should handle invalid version_id', async () => { const response = await fetch(`${BASE_URL}/bundle/metadata`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, version_id: 999999, // Non-existent version ID link: 'https://example.com', }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_find_version') }) it('should handle invalid app_id', async () => { const response = await fetch(`${BASE_URL}/bundle/metadata`, { method: 'POST', headers, body: JSON.stringify({ app_id: 'invalid_app', version_id: versionId, link: 'https://example.com', }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('invalid_app_id') // Changed: validation now catches invalid format first }) }) describe('[DELETE] /bundle operations', () => { it('invalid version', async () => { const response = await fetch(`${BASE_URL}/bundle`, { method: 'DELETE', headers, body: JSON.stringify({ app_id: APPNAME, version: 'invalid_version', }), }) await response.arrayBuffer() expect(response.status).toBe(400) }) it('invalid app_id', async () => { const response = await fetch(`${BASE_URL}/bundle`, { method: 'DELETE', headers, body: JSON.stringify({ app_id: 'invalid_app', }), }) await response.arrayBuffer() expect(response.status).toBe(400) }) it('delete specific bundle', async () => { const deleteBundle = await fetch(`${BASE_URL}/bundle`, { method: 'DELETE', headers, body: JSON.stringify({ app_id: APPNAME, version: '1.0.1', }), }) const deleteBundleData = await deleteBundle.json() as { status: string } expect(deleteBundle.status).toBe(200) expect(deleteBundleData.status).toBe('ok') }) it('does not delete a rollout target bundle linked to a channel', async () => { const supabase = getSupabaseClient() const stableVersion = await createAppVersions(`1.0.0-delete-rollout-stable-${id}`, APPNAME) const rolloutVersion = await createAppVersions(`1.0.0-delete-rollout-target-${id}`, APPNAME) const channelName = `delete-rollout-${id}` const { error: channelError } = await supabase .from('channels') .insert({ app_id: APPNAME, name: channelName, version: stableVersion.id, rollout_version: rolloutVersion.id, rollout_enabled: true, rollout_percentage_bps: 1000, owner_org: ORG_ID, created_by: USER_ID, }) expect(channelError).toBeNull() const deleteBundle = await fetch(`${BASE_URL}/bundle`, { method: 'DELETE', headers, body: JSON.stringify({ app_id: APPNAME, version: rolloutVersion.name, }), }) const deleteBundleData = await deleteBundle.json() as { error?: string } expect(deleteBundle.status).toBe(400) expect(deleteBundleData.error).toBe('cannot_delete_linked_version') const { data: versionAfterDelete, error: versionError } = await supabase .from('app_versions') .select('deleted') .eq('id', rolloutVersion.id) .single() expect(versionError).toBeNull() expect(versionAfterDelete?.deleted).toBe(false) }) it('delete all bundles for an app', async () => { const deleteAllBundles = await fetch(`${BASE_URL}/bundle`, { method: 'DELETE', headers, body: JSON.stringify({ app_id: APPNAME, }), }) const deleteAllBundlesData = await deleteAllBundles.json() as { status: string } expect(deleteAllBundles.status).toBe(200) expect(deleteAllBundlesData.status).toBe('ok') }) }) describe('[PUT] /bundle operations - Set bundle to channel', () => { let versionId: number let channelId: number let writeScopedKeyId: number | undefined let writeScopedHeaders: Record | undefined beforeAll(async () => { // Create a test version const version = await createAppVersions('1.0.0-test-channel', APPNAME) versionId = version.id const supabase = getSupabaseClient() // Create a test channel using proper seeded values const { data: channel, error } = await supabase .from('channels') .insert({ name: 'test-channel', app_id: APPNAME, version: null, created_by: '6aa76066-55ef-4238-ade6-0b32334a4097', // test@capgo.app user from seed owner_org: '046a36ac-e03c-4590-9257-bd6c9dba9ee8', // Demo org from seed }) .select() .single() if (error) { throw new Error(`Failed to create test channel: ${error.message}`) } if (!channel) { throw new Error('Failed to create test channel: channel is null') } channelId = channel.id const createKeyData = await createDirectApiKeyWithBindings({ key: randomUUID(), name: `bundle-write-key-${APPNAME}`, orgId: ORG_ID, roleName: 'org_member', appId: APPNAME, appRoleName: 'app_developer', }) if (!createKeyData.key) throw new Error('Failed to create write-scoped bundle key') writeScopedKeyId = createKeyData.id writeScopedHeaders = { 'Content-Type': 'application/json', 'capgkey': createKeyData.key, } }) afterAll(async () => { if (writeScopedKeyId != null) { await getSupabaseClient().from('apikeys').delete().eq('id', writeScopedKeyId) } }) it('should set bundle to channel successfully', async () => { const response = await putBundleToChannel({ app_id: APPNAME, version_id: versionId, channel_id: channelId, }) const data = await response.json() as { status: string, message: string } expect(response.status).toBe(200) expect(data.status).toBe('success') expect(data.message).toContain('Bundle') expect(data.message).toContain('set to channel') // Verify the channel was updated in the database const supabase = getSupabaseClient() const { data: channel, error } = await supabase .from('channels') .select('*') .eq('id', channelId) .eq('app_id', APPNAME) .single() expect(error).toBeNull() expect(channel).toBeTruthy() if (channel) { expect(channel.version).toBe(versionId) } }) it('should reset leftover rollout when a new stable bundle is set', async () => { const supabase = getSupabaseClient() const nextVersion = await createAppVersions('1.0.1-test-channel-rollout-reset', APPNAME) const rolloutVersion = await createAppVersions('1.0.0-test-channel-rollout-target', APPNAME) const { error: leftoverError } = await supabase .from('channels') .update({ version: versionId, rollout_version: rolloutVersion.id, rollout_enabled: true, rollout_percentage_bps: 10000, }) .eq('id', channelId) .eq('app_id', APPNAME) expect(leftoverError).toBeNull() const { data: before, error: beforeError } = await supabase .from('channels') .select('version, rollout_version, rollout_enabled, rollout_id') .eq('id', channelId) .single() expect(beforeError).toBeNull() expect(before?.version).toBe(versionId) expect(before?.rollout_version).toBe(rolloutVersion.id) expect(before?.rollout_enabled).toBe(true) const response = await putBundleToChannel({ app_id: APPNAME, version_id: nextVersion.id, channel_id: channelId, }) expect(response.status).toBe(200) const { data: after, error: afterError } = await supabase .from('channels') .select('version, rollout_version, rollout_enabled, rollout_percentage_bps, rollout_id') .eq('id', channelId) .single() expect(afterError).toBeNull() expect(after?.version).toBe(nextVersion.id) expect(after?.rollout_version).toBeNull() expect(after?.rollout_enabled).toBe(false) expect(after?.rollout_percentage_bps).toBe(0) expect(after?.rollout_id).not.toBe(before?.rollout_id) }) it('should keep the supported write-scoped API key bundle promotion flow working', async () => { if (!writeScopedHeaders) { throw new Error('Write-scoped bundle test key was not created') } const response = await fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers: writeScopedHeaders, body: JSON.stringify({ app_id: APPNAME, version_id: versionId, channel_id: channelId, }), }) const data = await response.json() as { status: string, message: string } expect(response.status).toBe(200) expect(data.status).toBe('success') expect(data.message).toContain('set to channel') const supabase = getSupabaseClient() const { data: channel, error } = await supabase .from('channels') .select('version') .eq('id', channelId) .eq('app_id', APPNAME) .single() expect(error).toBeNull() expect(channel?.version).toBe(versionId) }) it('should handle missing required fields', async () => { const response = await fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers, body: JSON.stringify({ // Missing channel_id app_id: APPNAME, version_id: versionId, }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('missing_required_fields') }) it('should handle invalid version_id', async () => { const response = await fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers, body: JSON.stringify({ app_id: APPNAME, version_id: 999999, // Non-existent version ID channel_id: channelId, }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_find_version') }) it('should handle invalid channel_id', async () => { const response = await fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers, body: JSON.stringify({ app_id: APPNAME, version_id: versionId, channel_id: 999999, // Non-existent channel ID }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('cannot_find_channel') }) it('should handle invalid app_id', async () => { const response = await fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers, body: JSON.stringify({ app_id: 'invalid_app', version_id: versionId, channel_id: channelId, }), }) expect(response.status).toBe(400) const data = await response.json() as { error: string } expect(data.error).toBe('invalid_app_id') // Changed: validation now catches invalid format first }) }) describe('[PUT] /bundle RBAC channel overrides', () => { let versionId: number let originalVersionId: number let channelId: number | undefined let rbacApiKeyId: number | undefined let rbacHeaders: Record | undefined beforeAll(async () => { const supabase = getSupabaseClient() const { error: orgError } = await supabase.from('orgs').insert({ id: RBAC_ORG_ID, name: `Bundle RBAC Org ${id}`, management_email: `bundle-rbac-${id}@capgo.app`, created_by: USER_ID, }) if (orgError) throw orgError const { data: app, error: appError } = await supabase .from('apps') .insert({ app_id: RBAC_APPNAME, name: `Bundle RBAC App ${id}`, icon_url: 'bundle-rbac-test-icon', owner_org: RBAC_ORG_ID, }) .select('id') .single() if (appError || !app) throw appError ?? new Error('Failed to create RBAC bundle app') const appId = app.id if (!appId) throw new Error('Created RBAC bundle app is missing id') const { error: memberError } = await supabase.from('org_users').insert({ org_id: RBAC_ORG_ID, user_id: USER_ID, rbac_role_name: 'org_member', }) if (memberError) throw memberError await supabase .from('role_bindings') .delete() .eq('principal_type', 'user') .eq('principal_id', USER_ID) .eq('scope_type', 'app') .eq('app_id', appId) const { data: role, error: roleError } = await supabase .from('roles') .select('id') .eq('name', 'app_developer') .single() if (roleError || !role) throw roleError ?? new Error('Failed to find app_developer role') const { error: bindingError } = await supabase.from('role_bindings').insert({ principal_type: 'user', principal_id: USER_ID, role_id: role.id, scope_type: 'app', org_id: RBAC_ORG_ID, app_id: appId, granted_by: USER_ID, is_direct: true, }) if (bindingError) throw bindingError originalVersionId = (await createAppVersions(`1.0.0-rbac-original-${id}`, RBAC_APPNAME, { owner_org: RBAC_ORG_ID, user_id: USER_ID, })).id versionId = (await createAppVersions(`1.0.0-rbac-target-${id}`, RBAC_APPNAME, { owner_org: RBAC_ORG_ID, user_id: USER_ID, })).id const { data: channel, error: channelError } = await supabase .from('channels') .insert({ name: `rbac-denied-channel-${id}`, app_id: RBAC_APPNAME, version: originalVersionId, created_by: USER_ID, owner_org: RBAC_ORG_ID, }) .select('id') .single() if (channelError || !channel) throw channelError ?? new Error('Failed to create RBAC denied channel') channelId = channel.id const apiKey = await createDirectApiKeyWithBindings({ userId: USER_ID, key: randomUUID(), name: `bundle-rbac-denied-${id}`, orgId: RBAC_ORG_ID, roleName: 'org_member', appId: RBAC_APPNAME, appRoleName: 'app_developer', }) if (!apiKey.key) throw new Error('Failed to create RBAC API key') const { error: overrideError } = await supabase.from('channel_permission_overrides').insert({ principal_type: 'apikey', principal_id: apiKey.rbac_id, channel_id: channelId, permission_key: 'channel.promote_bundle', is_allowed: false, }) if (overrideError) throw overrideError rbacApiKeyId = apiKey.id rbacHeaders = { 'Content-Type': 'application/json', 'capgkey': apiKey.key, } }) afterAll(async () => { const supabase = getSupabaseClient() if (rbacApiKeyId != null) await supabase.from('apikeys').delete().eq('id', rbacApiKeyId) if (channelId != null) await supabase.from('channel_permission_overrides').delete().eq('channel_id', channelId) await supabase.from('channels').delete().eq('app_id', RBAC_APPNAME) await supabase.from('app_versions').delete().eq('app_id', RBAC_APPNAME) await supabase.from('role_bindings').delete().eq('org_id', RBAC_ORG_ID) await supabase.from('org_users').delete().eq('org_id', RBAC_ORG_ID) await supabase.from('apps').delete().eq('app_id', RBAC_APPNAME) await supabase.from('orgs').delete().eq('id', RBAC_ORG_ID) }) it('rejects API-key bundle promotion when the user has a target-channel deny override', async () => { if (channelId == null) throw new Error('RBAC denied channel was not created') if (!rbacHeaders) throw new Error('RBAC API key was not created') const response = await fetch(`${BASE_URL}/bundle`, { method: 'PUT', headers: rbacHeaders, body: JSON.stringify({ app_id: RBAC_APPNAME, version_id: versionId, channel_id: channelId, }), }) const data = await response.json() as { error: string } expect(response.status).toBe(400) expect(data.error).toBe('cannot_access_app') const { data: channel, error } = await getSupabaseClient() .from('channels') .select('version') .eq('id', channelId) .single() expect(error).toBeNull() expect(channel?.version).toBe(originalVersionId) }) })