Ctrl K
ComunidadGuías…

tests/cli.test.ts

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

Archivo original ↗Preguntar a KiBot
import { randomUUID } from 'node:crypto'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { uploadBundleSDK } from './cli-sdk-utils'
import { cleanupCli, getSemver, prepareCli, tempFileFolder } from './cli-utils'
import { BASE_URL, createDirectApiKeyWithBindings, createIsolatedSeedAppOptions, getSupabaseClient, headers, NON_OWNER_ORG_ID, resetAndSeedAppData, resetAppData, resetAppDataStats, USER_ID } from './test-utils'

async function createOrgBoundApiKey(orgId: string, roleName = 'org_admin') {
  const data = await createDirectApiKeyWithBindings({
    userId: USER_ID,
    key: randomUUID(),
    name: `cli-test-${randomUUID()}`,
    orgId,
    roleName,
  })

  expect(data.id).toBeTypeOf('number')
  expect(data.key).toBeTypeOf('string')
  if (!data.key)
    throw new Error('Failed to seed CLI API key')

  return {
    id: data.id,
    key: data.key,
  }
}

async function deleteScopedApiKey(id: number) {
  await fetch(`${BASE_URL}/apikey/${id}`, {
    method: 'DELETE',
    headers,
  })
}

describe('tests CLI upload', () => {
  const idSuccess = randomUUID()
  const APPNAME_success = `com.cli_${idSuccess}`
  const successSeedOptions = createIsolatedSeedAppOptions()

  beforeAll(async () => {
    await Promise.all([
      resetAndSeedAppData(APPNAME_success, successSeedOptions),
      prepareCli(APPNAME_success),
    ])
  })

  afterAll(async () => {
    await Promise.all([
      cleanupCli(APPNAME_success),
      resetAppData(APPNAME_success),
      resetAppDataStats(APPNAME_success),
    ])
  })

  it('should upload bundle successfully', async () => {
    const result = await uploadBundleSDK(APPNAME_success, getSemver(), 'production', {
      ignoreCompatibilityCheck: true,
    })
    expect(result.success).toBe(true)
  }, 60000)

  it('should link uploaded bundle to multiple comma-separated channels', async () => {
    const appName = `com.cli_multi_channel_${randomUUID()}`
    const extraChannel = `multi-${randomUUID().slice(0, 8)}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(appName, seedOptions),
      prepareCli(appName),
    ])

    try {
      const supabase = getSupabaseClient()
      const { data: versionData, error: versionError } = await supabase
        .from('app_versions')
        .select('id')
        .eq('app_id', appName)
        .limit(1)
        .single()

      expect(versionError).toBeNull()
      if (!versionData)
        throw new Error('Missing seeded app version')

      const { error: channelError } = await supabase
        .from('channels')
        .insert({
          name: extraChannel,
          app_id: appName,
          version: versionData.id,
          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,
        })

      expect(channelError).toBeNull()

      const version = getSemver()
      const result = await uploadBundleSDK(appName, version, `production,${extraChannel}`, {
        ignoreCompatibilityCheck: true,
      })
      expect(result.success).toBe(true)

      const { data: channels, error } = await supabase
        .from('channels')
        .select('name, version(name)')
        .eq('app_id', appName)
        .in('name', ['production', extraChannel])

      expect(error).toBeNull()
      const versionsByChannel = new Map((channels ?? []).map(row => [row.name, (row.version as any)?.name]))
      expect(versionsByChannel.get('production')).toBe(version)
      expect(versionsByChannel.get(extraChannel)).toBe(version)
    }
    finally {
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }
  }, 60000)

  it('creates a missing explicit channel before assigning the uploaded bundle', async () => {
    const appName = `com.cli_new_channel_upload_${randomUUID()}`
    const targetChannel = `upload-target-${randomUUID().slice(0, 8)}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(appName, seedOptions),
      prepareCli(appName),
    ])

    try {
      const version = getSemver()
      const result = await uploadBundleSDK(appName, version, targetChannel, {
        ignoreCompatibilityCheck: true,
      })
      expect(result.success).toBe(true)

      const { data: channel, error } = await getSupabaseClient()
        .from('channels')
        .select('name, version(name)')
        .eq('app_id', appName)
        .eq('name', targetChannel)
        .single()

      expect(error).toBeNull()
      expect(channel?.name).toBe(targetChannel)
      expect((channel?.version as any)?.name).toBe(version)
    }
    finally {
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }
  }, 60000)

  it('allows app_uploader to pass explicit channel upload preflight', async () => {
    const appName = `com.cli_channel_preflight_${randomUUID()}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(appName, seedOptions),
      prepareCli(appName),
    ])

    let createdApikeyId: number | null = null
    try {
      const createdApikey = await createDirectApiKeyWithBindings({
        userId: USER_ID,
        key: randomUUID(),
        name: `cli-channel-preflight-${randomUUID()}`,
        orgId: seedOptions.orgId,
        roleName: 'apikey_org_reader',
        appId: appName,
        appRoleName: 'app_uploader',
      })
      createdApikeyId = createdApikey.id

      const version = getSemver()
      const result = await uploadBundleSDK(appName, version, 'production', {
        ignoreCompatibilityCheck: true,
        apikey: createdApikey.key ?? undefined,
      })
      expect(result.success).toBe(true)

      const { count, error } = await getSupabaseClient()
        .from('app_versions')
        .select('id', { count: 'exact', head: true })
        .eq('app_id', appName)
        .eq('name', version)

      expect(error).toBeNull()
      expect(count).toBe(1)
    }
    finally {
      if (createdApikeyId !== null)
        await deleteScopedApiKey(createdApikeyId)
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }
  }, 60000)

  it('allows app_uploader to pass default channel upload preflight', async () => {
    const appName = `com.cli_default_channel_preflight_${randomUUID()}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(appName, seedOptions),
      prepareCli(appName),
    ])

    let createdApikeyId: number | null = null
    try {
      const createdApikey = await createDirectApiKeyWithBindings({
        userId: USER_ID,
        key: randomUUID(),
        name: `cli-default-channel-preflight-${randomUUID()}`,
        orgId: seedOptions.orgId,
        roleName: 'apikey_org_reader',
        appId: appName,
        appRoleName: 'app_uploader',
      })
      createdApikeyId = createdApikey.id

      const version = getSemver()
      const result = await uploadBundleSDK(appName, version, undefined, {
        ignoreCompatibilityCheck: true,
        apikey: createdApikey.key ?? undefined,
      })
      expect(result.success).toBe(true)

      const { count, error } = await getSupabaseClient()
        .from('app_versions')
        .select('id', { count: 'exact', head: true })
        .eq('app_id', appName)
        .eq('name', version)

      expect(error).toBeNull()
      expect(count).toBe(1)
    }
    finally {
      if (createdApikeyId !== null)
        await deleteScopedApiKey(createdApikeyId)
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }
  }, 60000)

  it('does not create a missing explicit channel when create-channel preflight fails', async () => {
    const appName = `com.cli_channel_no_orphan_${randomUUID()}`
    const targetChannel = `upload-target-${randomUUID().slice(0, 8)}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(appName, seedOptions),
      prepareCli(appName),
    ])

    let createdApikeyId: number | null = null
    try {
      const createdApikey = await createDirectApiKeyWithBindings({
        userId: USER_ID,
        key: randomUUID(),
        name: `cli-channel-no-orphan-${randomUUID()}`,
        orgId: seedOptions.orgId,
        roleName: 'apikey_org_reader',
        appId: appName,
        appRoleName: 'app_uploader',
      })
      createdApikeyId = createdApikey.id

      const result = await uploadBundleSDK(appName, getSemver(), targetChannel, {
        ignoreCompatibilityCheck: true,
        apikey: createdApikey.key ?? undefined,
      })
      expect(result.success).toBe(false)
      expect(result.error).toContain('app.create_channel')

      const { count, error } = await getSupabaseClient()
        .from('channels')
        .select('id', { count: 'exact', head: true })
        .eq('app_id', appName)
        .eq('name', targetChannel)

      expect(error).toBeNull()
      expect(count).toBe(0)
    }
    finally {
      if (createdApikeyId !== null)
        await deleteScopedApiKey(createdApikeyId)
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }
  }, 60000)

  it('fails self-assign upload before creating a bundle when channel settings permission is missing', async () => {
    const appName = `com.cli_self_assign_preflight_${randomUUID()}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(appName, seedOptions),
      prepareCli(appName),
    ])

    let createdApikeyId: number | null = null
    try {
      const createdApikey = await createDirectApiKeyWithBindings({
        userId: USER_ID,
        key: randomUUID(),
        name: `cli-self-assign-preflight-${randomUUID()}`,
        orgId: seedOptions.orgId,
        roleName: 'org_member',
        appId: appName,
        appRoleName: 'app_developer',
      })
      createdApikeyId = createdApikey.id

      const version = getSemver()
      const result = await uploadBundleSDK(appName, version, 'production', {
        ignoreCompatibilityCheck: true,
        selfAssign: true,
        apikey: createdApikey.key ?? undefined,
      })
      expect(result.success).toBe(false)
      expect(result.error).toContain('channel.update_settings')

      const { count, error } = await getSupabaseClient()
        .from('app_versions')
        .select('id', { count: 'exact', head: true })
        .eq('app_id', appName)
        .eq('name', version)

      expect(error).toBeNull()
      expect(count).toBe(0)
    }
    finally {
      if (createdApikeyId !== null)
        await deleteScopedApiKey(createdApikeyId)
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }
  }, 60000)

  it('should not upload same hash twice', async () => {
    const appName = `com.cli_duplicate_${randomUUID()}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(appName, seedOptions),
      prepareCli(appName),
    ])

    try {
      // First upload
      const version = getSemver()
      const firstUpload = await uploadBundleSDK(appName, version, 'production', {
        ignoreCompatibilityCheck: true,
      })
      expect(firstUpload.success).toBe(true)

      // Second upload with same content should be skipped
      const result2 = await uploadBundleSDK(appName, version, 'production', {
        ignoreCompatibilityCheck: true,
      })
      expect(result2.success).toBe(false)
      expect(
        result2.error?.includes('same bundle content')
        || result2.error?.includes('already exists'),
      ).toBe(true)
    }
    finally {
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }
  }, 60000)
})

describe('tests CLI upload options in parallel', () => {
  const sharedId = randomUUID()
  const SHARED_APPNAME = `com.cli_shared_${sharedId}`

  interface PreparedCliApp {
    id: string
    APPNAME: string
    seedOptions: ReturnType<typeof createIsolatedSeedAppOptions>
  }

  const sharedSeedOptions = createIsolatedSeedAppOptions()

  // Use Maps with unique keys for atomic access in concurrent tests
  const fileTestApps = new Map<string, PreparedCliApp>()
  const apiTestApps = new Map<string, PreparedCliApp>()
  const usedApps: Array<string> = []

  const prepareApp = async () => {
    const id = randomUUID()
    const APPNAME = `com.cli_ccr_${id}`
    const seedOptions = createIsolatedSeedAppOptions()
    await Promise.all([
      resetAndSeedAppData(APPNAME, seedOptions),
      prepareCli(APPNAME),
    ])
    return { id, APPNAME, seedOptions }
  }

  beforeAll(async () => {
    const promises = []

    promises.push(Promise.all([
      resetAndSeedAppData(SHARED_APPNAME, sharedSeedOptions),
      prepareCli(SHARED_APPNAME),
    ]))

    // Use unique keys for each test that needs an app
    promises.push(prepareApp().then(app => fileTestApps.set('code-check', app)))
    promises.push(prepareApp().then(app => apiTestApps.set('org-limited', app)))
    promises.push(prepareApp().then(app => apiTestApps.set('wrong-org', app)))

    await Promise.all(promises)
  })

  afterAll(async () => {
    const allApps = [SHARED_APPNAME, ...usedApps]

    await Promise.all(allApps.map(async (appName) => {
      await Promise.all([
        cleanupCli(appName),
        resetAppData(appName),
        resetAppDataStats(appName),
      ])
    }))
  })

  it.concurrent('test code check (missing notifyAppReady)', async () => {
    const app = fileTestApps.get('code-check')
    if (!app)
      throw new Error('No file test app available for code-check test')
    usedApps.push(app.APPNAME)

    const semver = getSemver()
    writeFileSync(join(tempFileFolder(app.APPNAME), 'dist', 'index.js'), 'import { CapacitorUpdater } from \'@capgo/capacitor-updater\';\nconsole.log("Hello world!!!");')

    const result = await uploadBundleSDK(app.APPNAME, semver, 'production', {
      ignoreCompatibilityCheck: true,
      disableCodeCheck: false, // Enable code check for this test
    })
    expect(result.success).toBe(false)
    expect(result.error).toContain('notifyAppReady')
  }, 60000)

  it('test --min-update-version', async () => {
    const semver = getSemver()
    const result = await uploadBundleSDK(SHARED_APPNAME, semver, 'production', {
      ignoreCompatibilityCheck: true,
      minUpdateVersion: '1.0.0',
    })
    expect(result.success).toBe(true)

    const supabase = getSupabaseClient()
    const { data, error } = await supabase
      .from('app_versions')
      .select('min_update_version')
      .eq('name', semver)
      .eq('app_id', SHARED_APPNAME)
      .single()

    expect(error).toBeNull()
    expect(data?.min_update_version).toBe('1.0.0')
  }, 60000)

  it('cannot upload with wrong api key', async () => {
    const testApiKey = randomUUID()
    const semver = getSemver()
    const result = await uploadBundleSDK(SHARED_APPNAME, semver, 'production', {
      ignoreCompatibilityCheck: true,
      apikey: testApiKey,
    })
    expect(result.success).toBe(false)
    expect(result.error).toContain('Capgo authentication failed')
  }, 60000)

  it.concurrent('should test upload with org-limited API key', async () => {
    const app = apiTestApps.get('org-limited')
    if (!app)
      throw new Error('No API test app available for org-limited test')
    usedApps.push(app.APPNAME)

    const semver = getSemver()
    let createdApikeyId: number | null = null
    let createdPlainKey: string | null = null

    try {
      const createdApikey = await createOrgBoundApiKey(app.seedOptions.orgId)
      createdApikeyId = createdApikey.id
      createdPlainKey = createdApikey.key

      const result = await uploadBundleSDK(app.APPNAME, semver, 'production', {
        ignoreCompatibilityCheck: true,
        apikey: createdPlainKey as string,
      })
      expect(result.success).toBe(true)
    }
    finally {
      if (createdApikeyId !== null)
        await deleteScopedApiKey(createdApikeyId)
    }
  }, 60000)

  it.concurrent('should fail upload with wrong org-limited API key', async () => {
    const app = apiTestApps.get('wrong-org')
    if (!app)
      throw new Error('No API test app available for wrong-org test')
    usedApps.push(app.APPNAME)

    const semver = getSemver()
    let createdApikeyId: number | null = null
    let createdPlainKey: string | null = null

    try {
      const createdApikey = await createOrgBoundApiKey(NON_OWNER_ORG_ID, 'org_member')
      createdApikeyId = createdApikey.id
      createdPlainKey = createdApikey.key

      const result = await uploadBundleSDK(app.APPNAME, semver, 'production', {
        ignoreCompatibilityCheck: true,
        apikey: createdPlainKey as string,
      })
      expect(result.success).toBe(false)
      expect(result.error).toBe('Capgo authentication failed: invalid Capgo API key or insufficient Capgo permissions.')
    }
    finally {
      if (createdApikeyId !== null)
        await deleteScopedApiKey(createdApikeyId)
    }
  }, 60000)
})