import type { Database } from '../src/types/database.types.ts' import { randomUUID } from 'node:crypto' import { env } from 'node:process' import { createClient } from '@supabase/supabase-js' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { fetchTestRequest, getAuthHeaders, getAuthHeadersForCredentials, getEndpointUrl, getSupabaseClient, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, USER_ID } from './test-utils.ts' const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true' // Dedicated org: the shared test user is super admin, a fresh user is org_member. const TEST_ID = randomUUID().slice(0, 8) const ORG_ID = randomUUID() const CUSTOMER_ID = `cus_app_creator_${TEST_ID}` const MEMBER_EMAIL = `app-creator-member-${TEST_ID}@capgo.app` const MEMBER_PASSWORD = 'testtest' let adminHeaders: Record let memberHeaders: Record let memberId = '' const createdAppIds: string[] = [] const createdKeyIds: number[] = [] async function createApp(headers: Record, suffix: string) { const appId = `com.test.appcreator.${TEST_ID}.${suffix}` const response = await fetchTestRequest(getEndpointUrl('/app'), { method: 'POST', headers, body: JSON.stringify({ owner_org: ORG_ID, app_id: appId, name: `App creator ${suffix}` }), }) const body = await response.json() as { id: string, app_id: string } expect(response.status, JSON.stringify(body)).toBe(200) createdAppIds.push(appId) return body } async function appRolesOf(userId: string, appUuid: string) { const { data, error } = await getSupabaseClient() .from('role_bindings') .select('roles(name)') .eq('principal_type', 'user') .eq('principal_id', userId) .eq('scope_type', 'app') .eq('app_id', appUuid) if (error) throw error return (data ?? []).map(row => (row as unknown as { roles: { name: string } }).roles.name) } function createKey(headers: Record, bindings: Record[]) { return fetchTestRequest(getEndpointUrl('/apikey'), { method: 'POST', headers, body: JSON.stringify({ name: `app-creator-key-${randomUUID().slice(0, 8)}`, bindings }), }) } beforeAll(async () => { const capgo = getSupabaseClient() adminHeaders = await getAuthHeaders() const { error: stripeError } = await capgo.from('stripe_info').insert({ customer_id: CUSTOMER_ID, product_id: 'prod_LQIregjtNduh4q', status: 'succeeded', is_good_plan: true, }) if (stripeError) throw stripeError const { error: orgError } = await capgo.from('orgs').insert({ id: ORG_ID, created_by: USER_ID, name: `App Creator Org ${TEST_ID}`, management_email: `app-creator-${TEST_ID}@capgo.app`, customer_id: CUSTOMER_ID, }) if (orgError) throw orgError const { error: adminMembershipError } = await capgo.from('org_users').insert({ org_id: ORG_ID, user_id: USER_ID, rbac_role_name: 'org_super_admin' }) if (adminMembershipError) throw adminMembershipError const { data: member, error: memberError } = await capgo.auth.admin.createUser({ email: MEMBER_EMAIL, password: MEMBER_PASSWORD, email_confirm: true, }) if (memberError || !member.user) throw memberError ?? new Error('Failed to create member user') memberId = member.user.id const { error: profileError } = await capgo.from('users').upsert({ id: memberId, email: MEMBER_EMAIL }) if (profileError) throw profileError const { error: memberMembershipError } = await capgo.from('org_users').insert({ org_id: ORG_ID, user_id: memberId, rbac_role_name: 'org_member' }) if (memberMembershipError) throw memberMembershipError const { data: memberRole, error: memberRoleError } = await capgo.from('roles').select('id').eq('name', 'org_member').single() if (memberRoleError) throw memberRoleError const { error: memberBindingError } = await capgo.from('role_bindings').insert({ principal_type: 'user', principal_id: memberId, role_id: memberRole.id, scope_type: 'org', org_id: ORG_ID, granted_by: USER_ID, }) if (memberBindingError) throw memberBindingError memberHeaders = await getAuthHeadersForCredentials(MEMBER_EMAIL, MEMBER_PASSWORD) }) afterAll(async () => { const capgo = getSupabaseClient() for (const keyId of createdKeyIds) await capgo.from('apikeys').delete().eq('id', keyId) for (const appId of createdAppIds) await capgo.from('apps').delete().eq('app_id', appId) await capgo.from('role_bindings').delete().eq('org_id', ORG_ID) await capgo.from('org_users').delete().eq('org_id', ORG_ID) await capgo.from('orgs').delete().eq('id', ORG_ID) await capgo.from('stripe_info').delete().eq('customer_id', CUSTOMER_ID) if (memberId) await capgo.auth.admin.deleteUser(memberId) }) describe.skipIf(USE_CLOUDFLARE)('app creators and app-scoped API keys', () => { it('makes an org_member admin of the app they create, and only of that one', async () => { const memberApp = await createApp(memberHeaders, 'member') expect(await appRolesOf(memberId, memberApp.id)).toEqual(['app_admin']) // The creator can now read their app through RLS, and only that one. const memberClient = createClient(SUPABASE_BASE_URL!, SUPABASE_ANON_KEY!, { auth: { persistSession: false, autoRefreshToken: false } }) const { error: signInError } = await memberClient.auth.signInWithPassword({ email: MEMBER_EMAIL, password: MEMBER_PASSWORD }) if (signInError) throw signInError // An org admin already administers every app: no redundant app binding. const adminApp = await createApp(adminHeaders, 'admin') expect(await appRolesOf(USER_ID, adminApp.id)).toEqual([]) const { data: visibleApps } = await memberClient.from('apps').select('id').eq('owner_org', ORG_ID) expect((visibleApps ?? []).map(app => app.id)).toEqual([memberApp.id]) }) it('lets an app admin create keys limited to their own app, nothing wider', async () => { const memberApp = await createApp(memberHeaders, 'keys') const adminApp = await createApp(adminHeaders, 'keys-admin') const ownApp = await createKey(memberHeaders, [{ role_name: 'app_developer', scope_type: 'app', org_id: ORG_ID, app_id: memberApp.id }]) const ownAppBody = await ownApp.json() as { id: number } expect(ownApp.status, JSON.stringify(ownAppBody)).toBe(200) createdKeyIds.push(ownAppBody.id) const orgWide = await createKey(memberHeaders, [{ role_name: 'org_member', scope_type: 'org', org_id: ORG_ID }]) expect(orgWide.status).toBe(403) const otherApp = await createKey(memberHeaders, [{ role_name: 'app_reader', scope_type: 'app', org_id: ORG_ID, app_id: adminApp.id }]) expect(otherApp.status).toBe(403) // Capped by the creator: app admins cannot hand out app_admin itself. const escalation = await createKey(memberHeaders, [{ role_name: 'app_admin', scope_type: 'app', org_id: ORG_ID, app_id: memberApp.id }]) expect(escalation.status).toBe(403) }) })