import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { parseAppOnboarding } from '../supabase/functions/_backend/utils/appOnboarding.ts' import { BASE_URL, createDirectApiKeyWithBindings, executeSQL, fetchTestRequest, getAuthHeaders, getAuthHeadersForCredentials, getSupabaseClient, headers, ORG_ID, ORG_ID_2, resetAndSeedAppData, resetAppData, resetAppDataStats, SUPABASE_ANON_KEY, USER_EMAIL_NONMEMBER, USER_ID, USER_ID_2, USER_PASSWORD_NONMEMBER } from './test-utils.ts' function isDuplicateAppCreationError(body: any): boolean { if (!body || typeof body !== 'object') return false const errorCode = typeof body.error === 'string' ? body.error : '' const supabaseCode = typeof body?.supabaseError?.code === 'string' ? body.supabaseError.code : '' const moreInfoConstraint = typeof body?.moreInfo?.constraint === 'string' ? body.moreInfo.constraint : '' const constraint = typeof body?.constraint === 'string' ? body.constraint : '' if (errorCode === 'app_id_already_exists') return true const hasExplicitDuplicateSignal = supabaseCode === '23505' || moreInfoConstraint === 'apps_pkey' || constraint === 'apps_pkey' return errorCode === 'cannot_create_app' && hasExplicitDuplicateSignal } describe('[DELETE] /app operations', () => { const id = randomUUID() const APPNAME = `com.app.${id}` const createBody = { app_id: APPNAME, owner_org: ORG_ID, name: `App ${APPNAME}`, icon: 'https://cdn.example/test-icon.png', } afterAll(async () => { await resetAppData(APPNAME) await resetAppDataStats(APPNAME) }) it('should delete app and all associated data', async () => { // Create a test app const createApp = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers, body: JSON.stringify(createBody), }) if (createApp.status !== 200) { const body = await createApp.json().catch(() => null) as any const isDuplicate = isDuplicateAppCreationError(body) if (!isDuplicate) { expect(createApp.status, JSON.stringify(body)).toBe(200) } } await resetAndSeedAppData(APPNAME) // Delete the app const deleteApp = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'DELETE', headers, }) if (deleteApp.status !== 200) { const body = await deleteApp.json().catch(() => null) expect(deleteApp.status, JSON.stringify(body)).toBe(200) } // Verify app is deleted const checkApp = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'GET', headers, }) expect(checkApp.status).toBe(401) // Verify version is deleted const checkVersion = await fetchTestRequest(`${BASE_URL}/bundle/${APPNAME}/1.0.0`, { method: 'GET', headers, }) expect(checkVersion.status).toBe(404) // Verify channel devices are deleted const checkDevices = await fetchTestRequest(`${BASE_URL}/device/${APPNAME}`, { method: 'GET', headers, }) expect(checkDevices.status).toBe(404) // Verify channels are deleted const checkChannels = await fetchTestRequest(`${BASE_URL}/channel/${APPNAME}`, { method: 'GET', headers, }) expect(checkChannels.status).toBe(404) }) }) describe('[GET] /app operations with subkey', () => { const id = randomUUID() const APPNAME = `com.subkey.${id}` let subkey = 0 afterAll(async () => { await resetAppData(APPNAME) await resetAppDataStats(APPNAME) }) it('should create app and subkey with limited rights', async () => { // Create a test app const createApp = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers, retryUnsafe: true, body: JSON.stringify({ owner_org: ORG_ID, app_id: APPNAME, name: `App ${APPNAME}`, icon: 'https://cdn.example/test-icon.png', }), }) // Handle duplicate app creation gracefully on retry (app may already exist from a previous attempt) if (createApp.status !== 200) { const body = await createApp.json().catch(() => null) as any const isDuplicate = isDuplicateAppCreationError(body) if (!isDuplicate) { expect(createApp.status, JSON.stringify(body)).toBe(200) } } // Create a V2 subkey with limited rights to this app. const subkeyData = await createDirectApiKeyWithBindings({ key: randomUUID(), name: `Limited Subkey ${id}`, orgId: ORG_ID, roleName: 'org_member', appId: APPNAME, appRoleName: 'app_admin', }) subkey = subkeyData.id }) it('should access app with subkey', async () => { // Access app with subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } const getAppWithSubkey = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'GET', headers: { ...headers, ...subkeyHeaders }, }) expect(getAppWithSubkey.status).toBe(200) const data = await getAppWithSubkey.json() as { name: string } expect(data.name).toBe(`App ${APPNAME}`) }) it('should not access another app with subkey', async () => { // Create another app const otherAppId = randomUUID() const OTHER_APPNAME = `com.other.subkey.${otherAppId}` const createOtherApp = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers, retryUnsafe: true, body: JSON.stringify({ owner_org: ORG_ID, app_id: OTHER_APPNAME, name: `App ${OTHER_APPNAME}`, icon: 'https://cdn.example/test-icon.png', }), }) if (createOtherApp.status !== 200) { const body = await createOtherApp.json().catch(() => null) as any const isDuplicate = isDuplicateAppCreationError(body) if (!isDuplicate) { expect(createOtherApp.status, JSON.stringify(body)).toBe(200) } } // Try to access the other app with the subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } const getOtherAppWithSubkey = await fetchTestRequest(`${BASE_URL}/app/${OTHER_APPNAME}`, { method: 'GET', headers: { ...headers, ...subkeyHeaders }, }) const data = await getOtherAppWithSubkey.json() expect(data).toHaveProperty('error', 'cannot_access_app') expect(getOtherAppWithSubkey.status).toBe(401) // Clean up the other app await resetAppData(OTHER_APPNAME) await resetAppDataStats(OTHER_APPNAME) }) it('should update app with subkey', async () => { // Update app with subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } const updateApp = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'PUT', headers: { ...headers, ...subkeyHeaders }, retryUnsafe: true, body: JSON.stringify({ name: APPNAME, icon: 'https://cdn.example/updated-icon.png', }), }) expect(updateApp.status).toBe(200) }) it('should not delete app with subkey if rights are read-only', async () => { // Attempt to delete app with subkey const subkeyData = await createDirectApiKeyWithBindings({ key: randomUUID(), name: `Limited reader subkey ${id}`, orgId: ORG_ID, roleName: 'org_member', appId: APPNAME, appRoleName: 'app_reader', }) const subkeyHeaders = { 'x-limited-key-id': String(subkeyData.id) } const deleteApp = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'DELETE', headers: { ...headers, ...subkeyHeaders }, retryUnsafe: true, }) expect(deleteApp.status).toBe(400) const deleteAppData = await deleteApp.json() as { error: string } expect(deleteAppData.error).toBe('cannot_delete_app') }) it('should get all apps with subkey', async () => { // Get all apps with subkey const subkeyHeaders = { 'x-limited-key-id': String(subkey) } const getAllApps = await fetchTestRequest(`${BASE_URL}/app`, { method: 'GET', headers: { ...headers, ...subkeyHeaders }, }) expect(getAllApps.status).toBe(200) const appsDataWithSubkey = await getAllApps.json() as { name: string }[] const appNamesWithSubkey = appsDataWithSubkey.map(app => app.name) expect(appNamesWithSubkey).toContain(APPNAME) }) it('should get all apps without subkey', async () => { // Get all apps without subkey const getAllApps = await fetchTestRequest(`${BASE_URL}/app`, { method: 'GET', headers, }) expect(getAllApps.status).toBe(200) const appsDataWithoutSubkey = await getAllApps.json() as { app_id: string }[] const appNamesWithoutSubkey = appsDataWithoutSubkey.map(app => app.app_id) expect(appNamesWithoutSubkey).toContain(APPNAME) }) }) describe('[GET] /app subkey ownership enforcement', () => { const appId = 'com.test2.app' let subkeyId = 0 afterAll(async () => { if (subkeyId) { await getSupabaseClient().from('apikeys').delete().eq('id', subkeyId) } }) it('should reject subkey id owned by another user', async () => { const subkeyData = await createDirectApiKeyWithBindings({ userId: USER_ID_2, key: randomUUID(), name: `Cross-tenant subkey ${randomUUID()}`, orgId: ORG_ID_2, roleName: 'org_member', appId, appRoleName: 'app_reader', }) expect(subkeyData?.id).toBeTruthy() subkeyId = subkeyData.id const subkeyHeaders = { 'x-limited-key-id': String(subkeyId) } const response = await fetch(`${BASE_URL}/app/${appId}`, { method: 'GET', headers: { ...headers, ...subkeyHeaders }, }) expect(response.status).toBe(401) const data = await response.json() as { error?: string } expect(data.error).toBe('invalid_subkey') }) }) describe('/app hashed subkey enforcement', () => { const id = randomUUID() const ALLOWED_APPNAME = `com.hashed-subkey.allowed.${id}` const BLOCKED_APPNAME = `com.hashed-subkey.blocked.${id}` const SUBKEY_NAME = `Hashed Limited Subkey ${id}` let subkeyId = 0 async function createAppForTest(appName: string) { const createApp = await fetch(`${BASE_URL}/app`, { method: 'POST', headers, body: JSON.stringify({ owner_org: ORG_ID, app_id: appName, name: `App ${appName}`, icon: 'https://cdn.example/test-icon.png', }), }) if (createApp.status !== 200) { const body = await createApp.json().catch(() => null) as any const isDuplicate = isDuplicateAppCreationError(body) if (!isDuplicate) { expect(createApp.status, JSON.stringify(body)).toBe(200) } } } beforeAll(async () => { await createAppForTest(ALLOWED_APPNAME) const subkeyData = await createDirectApiKeyWithBindings({ key: randomUUID(), name: SUBKEY_NAME, orgId: ORG_ID, roleName: 'org_member', appId: ALLOWED_APPNAME, appRoleName: 'app_admin', hashed: true, }) subkeyId = subkeyData.id }) afterAll(async () => { if (subkeyId) { await getSupabaseClient().from('apikeys').delete().eq('id', subkeyId) } await resetAppData(ALLOWED_APPNAME) await resetAppDataStats(ALLOWED_APPNAME) await resetAppData(BLOCKED_APPNAME) await resetAppDataStats(BLOCKED_APPNAME) }) it('should reject hashed subkeys on middlewareKey routes', async () => { const response = await fetch(`${BASE_URL}/app/${ALLOWED_APPNAME}`, { method: 'GET', headers: { ...headers, 'x-limited-key-id': String(subkeyId) }, }) expect(response.status).toBe(401) const data = await response.json() as { error?: string } expect(data.error).toBe('invalid_subkey') }) it('should reject hashed subkeys on JWT/API-key auth routes', async () => { const response = await fetch(`${BASE_URL}/app`, { method: 'POST', headers: { ...headers, 'x-limited-key-id': String(subkeyId) }, body: JSON.stringify({ owner_org: ORG_ID, app_id: BLOCKED_APPNAME, name: `App ${BLOCKED_APPNAME}`, icon: 'https://cdn.example/test-icon.png', }), }) expect(response.status).toBe(401) const data = await response.json() as { error?: string } expect(data.error).toBe('invalid_subkey') }) }) describe('[GET] /app invalid subkey header handling', () => { it.concurrent('should reject malformed x-limited-key-id values instead of ignoring them', async () => { const invalidSubkeyValues = ['', '0', '73603x', '73603,', ' 73603', '9007199254740992'] for (const invalidSubkeyValue of invalidSubkeyValues) { const response = await fetch(`${BASE_URL}/app?limit=1`, { method: 'GET', headers: { ...headers, 'x-limited-key-id': invalidSubkeyValue, }, }) expect(response.status).toBe(401) const data = await response.json() as { error?: string } expect(data.error).toBe('invalid_subkey') } }) }) describe('[POST] /app operations with non-owner user', () => { const id = randomUUID() const safeId = id.replaceAll('-', '') const noAccessOrgId = randomUUID() const adminAccessOrgId = randomUUID() const noAccessCustomerId = `cus_nonowner_no_${safeId}` const adminAccessCustomerId = `cus_nonowner_admin_${safeId}` const noAccessAppName = `com.nonowner.denied.${safeId}` const adminAccessAppName = `com.nonowner.allowed.${safeId}` let authHeaders: Record beforeAll(async () => { authHeaders = await getAuthHeaders() const supabase = getSupabaseClient() const { error: stripeError } = await supabase.from('stripe_info').insert([ { customer_id: noAccessCustomerId, status: 'succeeded', product_id: 'prod_LQIregjtNduh4q', subscription_id: `sub_no_${safeId}`, trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), is_good_plan: true, }, { customer_id: adminAccessCustomerId, status: 'succeeded', product_id: 'prod_LQIregjtNduh4q', subscription_id: `sub_admin_${safeId}`, trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), is_good_plan: true, }, ]) if (stripeError) throw stripeError const { error: orgError } = await supabase.from('orgs').insert([ { id: noAccessOrgId, name: `No access app org ${safeId}`, management_email: `no-access-${safeId}@capgo.test`, created_by: USER_ID_2, customer_id: noAccessCustomerId, }, { id: adminAccessOrgId, name: `Admin access app org ${safeId}`, management_email: `admin-access-${safeId}@capgo.test`, created_by: USER_ID_2, customer_id: adminAccessCustomerId, }, ]) if (orgError) throw orgError const { error: orgUserError } = await supabase.from('org_users').insert({ org_id: noAccessOrgId, user_id: USER_ID, rbac_role_name: 'org_member', }) if (orgUserError) throw orgUserError await executeSQL(` DELETE FROM public.role_bindings WHERE principal_type = public.rbac_principal_user() AND principal_id = $1::uuid AND org_id = $2::uuid `, [USER_ID, noAccessOrgId]) await executeSQL(` INSERT INTO public.role_bindings ( principal_type, principal_id, role_id, scope_type, org_id, granted_by, reason, is_direct ) SELECT public.rbac_principal_user(), $1::uuid, roles.id, public.rbac_scope_org(), $2::uuid, $1::uuid, 'app creation test admin binding', true FROM public.roles WHERE roles.name = public.rbac_role_org_admin() AND roles.scope_type = public.rbac_scope_org() LIMIT 1 `, [USER_ID, adminAccessOrgId]) }) afterAll(async () => { await resetAppData(noAccessAppName) await resetAppDataStats(noAccessAppName) await resetAppData(adminAccessAppName) await resetAppDataStats(adminAccessAppName) const supabase = getSupabaseClient() await executeSQL(` DELETE FROM public.role_bindings WHERE principal_type = public.rbac_principal_user() AND principal_id = $1::uuid AND org_id = ANY($2::uuid[]) `, [USER_ID, [noAccessOrgId, adminAccessOrgId]]) await supabase.from('org_users').delete().eq('user_id', USER_ID).in('org_id', [noAccessOrgId, adminAccessOrgId]) await supabase.from('orgs').delete().in('id', [noAccessOrgId, adminAccessOrgId]) await supabase.from('stripe_info').delete().in('customer_id', [noAccessCustomerId, adminAccessCustomerId]) }) it('should ignore stale org_users role metadata when creating an app', async () => { const createApp = await fetch(`${BASE_URL}/app`, { method: 'POST', headers: authHeaders, body: JSON.stringify({ owner_org: noAccessOrgId, app_id: noAccessAppName, name: `${noAccessAppName}_no_access`, icon: 'https://cdn.example/test-icon.png', }), }) expect(createApp.status).toBe(403) const responseData = await createApp.json() expect(responseData).toHaveProperty('error', 'cannot_access_organization') }) it('should allow app creation in an organization where user has an RBAC admin binding', async () => { const createApp = await fetch(`${BASE_URL}/app`, { method: 'POST', headers: authHeaders, body: JSON.stringify({ owner_org: adminAccessOrgId, app_id: adminAccessAppName, name: `App ${adminAccessAppName}`, icon: 'https://cdn.example/test-icon.png', }), }) expect(createApp.status).toBe(200) const responseData = await createApp.json() expect(responseData).toHaveProperty('app_id', adminAccessAppName) }) }) describe('[POST]/[PUT] /app onboarding progress', () => { const id = randomUUID() const APPNAME = `com.onboarding.progress.${id}` afterAll(async () => { await resetAppData(APPNAME) await resetAppDataStats(APPNAME) }) it('defaults new apps to manual onboarding and merges CLI progress', async () => { const createApp = await fetchTestRequest(`${BASE_URL}/app`, { method: 'POST', headers, body: JSON.stringify({ owner_org: ORG_ID, app_id: APPNAME, name: `App ${APPNAME}`, icon: 'https://cdn.example/test-icon.png', }), }) expect(createApp.status).toBe(200) const created = parseAppOnboarding((await createApp.json() as { onboarding?: unknown }).onboarding) expect(created.source).toBe('manual') expect(created.outcome).toBe('in_progress') // Every new non-Builder app starts on the seven-step OTA checklist. expect(created.todo_list_version).toBe(4) expect(created.ota_todo_list_version).toBe('1') const firstPut = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'PUT', headers, body: JSON.stringify({ onboarding: { source: 'cli', steps: { login_cli_mcp: { status: 'done' }, }, }, }), }) expect(firstPut.status).toBe(200) const afterCli = parseAppOnboarding((await firstPut.json() as { onboarding?: unknown }).onboarding) expect(afterCli.source).toBe('cli') expect(afterCli.steps.login_cli_mcp?.status).toBe('done') const skipPut = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'PUT', headers, body: JSON.stringify({ onboarding: { source: 'ai', steps: { login_cli_mcp: { status: 'skipped' }, add_channel: { status: 'skipped' }, }, }, }), }) expect(skipPut.status).toBe(200) const afterSkip = parseAppOnboarding((await skipPut.json() as { onboarding?: unknown }).onboarding) expect(afterSkip.source).toBe('cli') expect(afterSkip.steps.login_cli_mcp?.status).toBe('done') expect(afterSkip.steps.add_channel?.status).toBe('skipped') // Partial CLI progress stays in_progress. Missing steps are not skipped. expect(afterSkip.outcome).toBe('in_progress') }) it('updates app settings and onboarding progress with an authenticated JWT', async () => { const jwtHeaders = await getAuthHeaders() const jwtPut = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'PUT', headers: jwtHeaders, body: JSON.stringify({ name: `JWT ${APPNAME}`, onboarding: { outcome: 'switched_to_manual', }, }), }) const updated = await jwtPut.json() as { name?: string, onboarding?: unknown } expect(jwtPut.status, JSON.stringify(updated)).toBe(200) expect(updated.name).toBe(`JWT ${APPNAME}`) expect(parseAppOnboarding(updated.onboarding).outcome).toBe('switched_to_manual') }) it('rejects an app update from an authenticated non-member', async () => { const nonMemberHeaders = await getAuthHeadersForCredentials(USER_EMAIL_NONMEMBER, USER_PASSWORD_NONMEMBER) const deniedPut = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'PUT', headers: nonMemberHeaders, body: JSON.stringify({ name: `Denied ${APPNAME}`, }), }) const denied = await deniedPut.json() as { error?: string } expect(deniedPut.status, JSON.stringify(denied)).toBe(401) expect(denied.error).toBe('cannot_access_app') }) it('prefers an explicit Capgo key over a simultaneous project bearer token', async () => { const cliPut = await fetchTestRequest(`${BASE_URL}/app/${APPNAME}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${SUPABASE_ANON_KEY}`, 'capgkey': headers.Authorization, }, body: JSON.stringify({ name: `CLI ${APPNAME}`, }), }) const updated = await cliPut.json() as { name?: string } expect(cliPut.status, JSON.stringify(updated)).toBe(200) expect(updated.name).toBe(`CLI ${APPNAME}`) }) })