Ctrl K
ComunidadGuías…

tests/app.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string>

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