Ctrl K
ComunidadGuías…

tests/bundle-upload-session.test.ts

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

Archivo original ↗Preguntar a KiBot
import { Buffer } from 'node:buffer'
import { createHash, randomUUID } from 'node:crypto'
import { brotliDecompressSync, crc32 } from 'node:zlib'
import AdmZip from 'adm-zip'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { parseCiCdSetupState } from '../src/utils/cicdSetupState.ts'
import { BASE_URL, getBaseData, getSupabaseClient, headers, postUpdate, resetAndSeedAppData, resetAppData } from './test-utils.ts'

const id = randomUUID()
const APPNAME = `com.upload.session.${id}`
const UPDATER_VERSION = '7.40.0'

function bundleZip(js = 'CapacitorUpdater.notifyAppReady()', extra: Record<string, string> = {}) {
  const zip = new AdmZip()
  zip.addFile('index.html', Buffer.from('<html><script src="app.js"></script></html>'))
  zip.addFile('app.js', Buffer.from(js))
  for (const [name, content] of Object.entries(extra))
    zip.addFile(name, Buffer.from(content))
  return zip.toBuffer()
}

async function api(path: string, body: unknown, extraHeaders: Record<string, string> = {}) {
  const response = await fetch(`${BASE_URL}/bundle/upload/${path}`, { method: 'POST', headers: { ...headers, ...extraHeaders }, body: JSON.stringify(body) })
  return { status: response.status, data: await response.json() as any }
}

async function prepare(version: string, overrides: Record<string, unknown> = {}) {
  return api('prepare', {
    app_id: APPNAME,
    version,
    channel: 'production',
    updater_version: UPDATER_VERSION,
    native_packages: [],
    ...overrides,
  })
}

async function putZip(url: string, zip: Buffer) {
  const response = await fetch(url, { method: 'PUT', body: new Uint8Array(zip), headers: { 'Content-Type': 'application/zip' } })
  expect(response.ok).toBe(true)
}

async function versionRow(name: string) {
  const { data } = await getSupabaseClient().from('app_versions').select('id, storage_provider, checksum, deleted, native_packages, min_update_version, cli_version, owner_org, r2_path').eq('app_id', APPNAME).eq('name', name).maybeSingle()
  return data
}

function sha256(bytes: Uint8Array) {
  return createHash('sha256').update(bytes).digest('hex')
}

/** Hash of the live bundle object, read straight from storage. */
async function liveBundleHash(name: string) {
  const row = await versionRow(name)
  const { data, error } = await getSupabaseClient().storage.from('capgo').download(`orgs/${row!.owner_org}/apps/${APPNAME}/${name}.zip`)
  expect(error).toBeNull()
  return sha256(new Uint8Array(await data!.arrayBuffer()))
}

beforeAll(async () => {
  await resetAndSeedAppData(APPNAME)
})

afterAll(async () => {
  await resetAppData(APPNAME)
})

describe('bundle upload sessions', () => {
  it('uploads a bundle end to end like `capgo bundle upload`', async () => {
    const zip = bundleZip()
    const prepared = await prepare('2.0.0', { comment: 'from mcp', cli_version: '8.95.0' })
    expect(prepared.status).toBe(200)
    expect(prepared.data.upload_token).toBeTypeOf('string')
    expect(prepared.data.bundle_upload.method).toBe('PUT')
    expect(prepared.data.checksum_algorithm).toBe('sha256')
    expect(prepared.data.channels).toEqual([expect.objectContaining({ name: 'production', compatibility: 'compatible' })])
    expect((await versionRow('2.0.0'))?.storage_provider).toBe('r2-direct')

    const early = await api('complete', { upload_token: prepared.data.upload_token }, { 'x-cli-ci': 'GitHub Actions' })
    expect(early.status).toBe(409)
    expect(early.data.error).toBe('upload_missing')
    expect((await versionRow('2.0.0'))?.deleted).toBe(false)
    const readCiState = async () => {
      const { data } = await getSupabaseClient().from('apps').select('onboarding').eq('app_id', APPNAME).single()
      return parseCiCdSetupState(data?.onboarding)
    }
    expect((await readCiState()).otaDetectedAt).toBeNull()

    await putZip(prepared.data.bundle_upload.url, zip)
    const completed = await api('complete', { upload_token: prepared.data.upload_token }, { 'x-cli-ci': 'GitHub Actions' })
    expect(completed.status).toBe(200)
    expect(completed.data.checksum).toBe(createHash('sha256').update(zip).digest('hex'))
    expect(completed.data.channels_updated).toEqual(['production'])
    await expect.poll(async () => (await readCiState()).otaDetectedAt).toBeTruthy()
    expect(await readCiState()).toMatchObject({ otaDetectedVia: 'ci', otaCiProvider: 'GitHub Actions' })

    const row = await versionRow('2.0.0')
    expect(row).toMatchObject({ storage_provider: 'r2', checksum: completed.data.checksum, cli_version: '8.95.0' })
    const { data: channel } = await getSupabaseClient().from('channels').select('version').eq('app_id', APPNAME).eq('name', 'production').single()
    expect(channel?.version).toBe(row?.id)

    const again = await api('complete', { upload_token: prepared.data.upload_token })
    expect(again.status).toBe(409)
    expect(again.data.error).toBe('upload_already_completed')

    // Same content again: the channel already serves this checksum.
    const prepareSame = await prepare('2.0.1', { checksum: completed.data.checksum })
    expect(prepareSame.status).toBe(400)
    expect(prepareSame.data.error).toBe('same_checksum')
  })

  it('rejects a checksum mismatch and removes the unfinished version', async () => {
    const prepared = await prepare('3.0.0', { checksum: 'a'.repeat(64), ignore_checksum_check: true })
    expect(prepared.status).toBe(200)
    await putZip(prepared.data.bundle_upload.url, bundleZip('notifyAppReady(); // 3'))
    const completed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(completed.status).toBe(400)
    expect(completed.data.error).toBe('checksum_mismatch')
    expect(await versionRow('3.0.0')).toBeNull()
  })

  it('enforces notifyAppReady unless opted out', async () => {
    const prepared = await prepare('4.0.0')
    await putZip(prepared.data.bundle_upload.url, bundleZip('console.log("no ready call")'))
    const completed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(completed.status).toBe(400)
    expect(completed.data.error).toBe('notify_app_ready_missing')
    expect(await versionRow('4.0.0')).toBeNull()

    const optOut = await prepare('4.0.1', { ignore_notify_app_ready: true, channel: 'upload-session-optout' })
    expect(optOut.status).toBe(200)
    await putZip(optOut.data.bundle_upload.url, bundleZip('console.log("no ready call")'))
    const done = await api('complete', { upload_token: optOut.data.upload_token })
    expect(done.status).toBe(200)
    expect(done.data.channels_updated).toEqual(['upload-session-optout'])
  })

  it('aborts an unfinished upload, even after the zip was sent', async () => {
    const prepared = await prepare('5.0.0', { channel: 'upload-session-abort' })
    await putZip(prepared.data.bundle_upload.url, bundleZip('notifyAppReady(); // 5'))
    const aborted = await api('abort', { upload_token: prepared.data.upload_token })
    expect(aborted.status).toBe(200)
    expect(aborted.data.aborted).toBe(true)
    expect(await versionRow('5.0.0')).toBeNull()
  })

  it('applies version, option and compatibility checks at prepare', async () => {
    const exists = await prepare('2.0.0')
    expect(exists.status).toBe(409)
    expect(exists.data.error).toBe('version_already_exists')

    const existsOk = await prepare('2.0.0', { version_exists_ok: true })
    expect(existsOk.status).toBe(200)
    expect(existsOk.data).toMatchObject({ skipped: true, reason: 'VERSION_EXISTS' })

    const conflict = await prepare('6.0.0', { min_update_version: '1.0.0', auto_min_update_version: true })
    expect(conflict.data.error).toBe('invalid_options')

    const noUpdater = await prepare('6.0.0', { updater_version: undefined })
    expect(noUpdater.data.error).toBe('missing_updater_version')

    // A range would silently pick CRC32 for a SHA-256 updater: only the installed version is accepted.
    const rangeUpdater = await prepare('6.0.0', { updater_version: '^7.40.0' })
    expect(rangeUpdater.data.error).toBe('invalid_updater_version')
    const unknownPackage = await prepare('6.0.0', { updater_version: '^7.40.0', updater_package: 'unknown-updater' })
    expect(unknownPackage.data.error).toBe('invalid_updater_version')
    expect(await versionRow('6.0.0')).toBeNull()

    const unknownField = await prepare('6.0.0', { unexpected: true })
    expect(unknownField.data.error).toBe('invalid_body')

    const blocked = await prepare('6.0.0', { native_packages: [{ name: '@capacitor/camera', version: '6.0.0' }], fail_on_incompatible: true })
    expect(blocked.status).toBe(400)
    expect(blocked.data.error).toBe('incompatible_bundle')
    expect(await versionRow('6.0.0')).toBeNull()
  })

  it('keeps the packer checksum algorithm (--force-crc32-checksum)', async () => {
    const forcedSha = await prepare('14.0.0', { channel: 'upload-session-crc', updater_version: '5.0.0', checksum_algorithm: 'sha256' })
    expect(forcedSha.data.error).toBe('invalid_options')
    expect(await versionRow('14.0.0')).toBeNull()

    const zip = bundleZip('notifyAppReady(); // 14')
    const checksum = crc32(zip).toString(16).padStart(8, '0')
    const prepared = await prepare('14.0.0', { channel: 'upload-session-crc', checksum_algorithm: 'crc32', checksum })
    expect(prepared.status).toBe(200)
    expect(prepared.data.checksum_algorithm).toBe('crc32')
    await putZip(prepared.data.bundle_upload.url, zip)
    const completed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(completed.status).toBe(200)
    expect(completed.data.checksum).toBe(checksum)
  })

  it('resumes channel updates that failed after the bundle was finalized', async () => {
    // resume-b only accepts delta bundles, so linking this zip-only bundle fails there.
    const created = await fetch(`${BASE_URL}/channel`, { method: 'POST', headers, body: JSON.stringify({ app_id: APPNAME, channel: 'upload-session-resume-b', updatePackage: 'delta' }) })
    expect(created.ok).toBe(true)
    const prepared = await prepare('15.0.0', { channel: ['upload-session-resume-a', 'upload-session-resume-b'] })
    expect(prepared.status).toBe(200)
    await putZip(prepared.data.bundle_upload.url, bundleZip('notifyAppReady(); // 15'))
    const failed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(failed.data.error).toBe('channel_update_failed')
    expect(failed.data.moreInfo?.updated_channels ?? failed.data.updated_channels).toEqual(['upload-session-resume-a'])
    expect((await versionRow('15.0.0'))?.storage_provider).toBe('r2')

    await getSupabaseClient().from('channels').update({ update_package: 'all' }).eq('app_id', APPNAME).eq('name', 'upload-session-resume-b')
    const resumed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(resumed.status).toBe(200)
    expect(resumed.data).toMatchObject({ resumed: true, channels_updated: ['upload-session-resume-b'] })
    const { data: channel } = await getSupabaseClient().from('channels').select('version').eq('app_id', APPNAME).eq('name', 'upload-session-resume-b').single()
    expect(channel?.version).toBe((await versionRow('15.0.0'))?.id)

    const done = await api('complete', { upload_token: prepared.data.upload_token })
    expect(done.data.error).toBe('upload_already_completed')
  })

  it('refuses tampered tokens', async () => {
    const prepared = await prepare('7.0.0', { channel: 'upload-session-token' })
    const [prefix, body, signature] = (prepared.data.upload_token as string).split('.')
    const forged = `${prefix}.${body}x.${signature}`
    const completed = await api('complete', { upload_token: forged })
    expect(completed.status).toBe(401)
    expect(completed.data.error).toBe('invalid_upload_token')
    await api('abort', { upload_token: prepared.data.upload_token })
  })

  // Delta is a Workers-only feature, like the files config advertises to the CLI.
  it.skipIf(process.env.USE_CLOUDFLARE_WORKERS !== 'true')('builds the delta manifest from the uploaded zip', async () => {
    const big = 'notifyAppReady(); '.repeat(1000)
    const prepared = await prepare('9.0.0', { delta: true, channel: 'upload-session-delta' })
    expect(prepared.status).toBe(200)
    expect(prepared.data.delta).toBe('server')
    await putZip(prepared.data.bundle_upload.url, bundleZip(big, { 'assets/a.css': 'body{}' }))
    const completed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(completed.status).toBe(200)
    expect(completed.data.delta).toMatchObject({ mode: 'server', files: 3 })
    const row = await versionRow('9.0.0')
    const { data: manifest } = await getSupabaseClient().from('manifest').select('file_name, file_hash').eq('app_version_id', row!.id)
    // Like the CLI, files worth compressing are stored as Brotli under a `.br` name.
    expect(manifest?.map(entry => entry.file_name).sort()).toEqual(['app.js.br', 'assets/a.css', 'index.html'])
    const appJs = manifest?.find(entry => entry.file_name === 'app.js.br')
    expect(appJs?.file_hash).toBe(createHash('sha256').update(big).digest('hex'))

    // A device gets the manifest pointing at what the server wrote. Serve it on
    // production for one request, then put production back on its stable bundle.
    const { data: production } = await getSupabaseClient().from('channels').select('id, version').eq('app_id', APPNAME).eq('name', 'production').single()
    await getSupabaseClient().from('channels').update({ version: row!.id }).eq('id', production!.id)
    const update = await postUpdate({ ...getBaseData(APPNAME), device_id: randomUUID(), plugin_version: UPDATER_VERSION, version_name: '9.0.0-0', version_build: '9.0.0' })
    await getSupabaseClient().from('channels').update({ version: production!.version }).eq('id', production!.id)
    expect(update.status).toBe(200)
    const json = await update.json<{ version?: string, manifest?: Array<{ file_name: string, download_url: string }> }>()
    expect(json.version).toBe('9.0.0')
    const served = json.manifest?.find(entry => entry.file_name === 'app.js.br')
    expect(served?.download_url).toContain('/delta/')
    // Locally the plugin worker reads its own R2 emulation, not the S3 gateway the API writes
    // to (in production both are the same bucket), so check the stored bytes in storage directly.
    const storedPath = decodeURIComponent(new URL(served!.download_url).pathname.split('/files/read/attachments/')[1])
    const { data: stored, error } = await getSupabaseClient().storage.from('capgo').download(storedPath)
    expect(error).toBeNull()
    expect(brotliDecompressSync(Buffer.from(await stored!.arrayBuffer())).toString()).toBe(big)
  })

  it.skipIf(process.env.USE_CLOUDFLARE_WORKERS !== 'true')('publishes a delta pack and rejects tampered blobs', async () => {
    const files = { 'index.html': '<html></html>', 'app.js': 'notifyAppReady()' }
    function pack(tamper = false) {
      const zip = new AdmZip()
      const entries = Object.entries(files).map(([fileName, content], index) => {
        const bytes = Buffer.from(content)
        zip.addFile(`files/${index}`, tamper && index === 1 ? Buffer.from('evil') : bytes)
        return {
          id: index,
          file_name: fileName,
          compression: 'none',
          file_hash: createHash('sha256').update(bytes).digest('hex'),
          uploaded_bytes_sha256: createHash('sha256').update(bytes).digest('hex'),
          uploaded_bytes_size: bytes.length,
          pack_path: `files/${index}`,
        }
      })
      zip.addFile('capgo-delta-manifest.json', Buffer.from(JSON.stringify({ protocol_version: 1, file_hash_format: 'sha256_hex', delta_encryption: { enabled: false }, entries })))
      return zip.toBuffer()
    }

    const tampered = await prepare('10.0.0', { delta_pack: true, channel: 'upload-session-pack' })
    expect(tampered.data.delta).toBe('pack')
    await putZip(tampered.data.bundle_upload.url, bundleZip('notifyAppReady(); // 10'))
    await putZip(tampered.data.delta_pack_upload.url, pack(true))
    const rejected = await api('complete', { upload_token: tampered.data.upload_token })
    expect(rejected.status).toBe(400)
    expect(rejected.data.error).toBe('delta_pack_invalid')
    expect(await versionRow('10.0.0')).toBeNull()

    const prepared = await prepare('10.0.0', { delta_pack: true, channel: 'upload-session-pack' })
    await putZip(prepared.data.bundle_upload.url, bundleZip('notifyAppReady(); // 10'))
    await putZip(prepared.data.delta_pack_upload.url, pack())
    const completed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(completed.status).toBe(200)
    expect(completed.data.delta).toMatchObject({ mode: 'pack', files: 2 })
  })

  it('lets only one complete win and keeps the live bundle immutable', async () => {
    const zip = bundleZip('notifyAppReady(); // 12')
    const prepared = await prepare('12.0.0', { channel: 'upload-session-race' })
    expect(prepared.status).toBe(200)
    // Clients only ever get a signed URL for a staging key, never for the bundle path.
    expect(prepared.data.bundle_upload.url).toContain('/upload-sessions-expire-after-1-day/')
    expect(prepared.data.bundle_upload.url).not.toContain('12.0.0.zip')
    await putZip(prepared.data.bundle_upload.url, zip)

    const token = { upload_token: prepared.data.upload_token }
    const [first, second] = await Promise.all([api('complete', token), api('complete', token)])
    expect([first.status, second.status].sort()).toEqual([200, 409])
    const loser = first.status === 409 ? first : second
    expect(['upload_in_progress', 'upload_already_completed']).toContain(loser.data.error)
    expect(await versionRow('12.0.0')).toMatchObject({ storage_provider: 'r2', deleted: false, checksum: sha256(zip) })
    expect(await liveBundleHash('12.0.0')).toBe(sha256(zip))

    const again = await api('complete', token)
    expect(again.status).toBe(409)
    expect(again.data.error).toBe('upload_already_completed')
    const aborted = await api('abort', token)
    expect(aborted.status).toBe(409)
    expect(aborted.data.error).toBe('upload_already_completed')
    expect(await versionRow('12.0.0')).toMatchObject({ storage_provider: 'r2', deleted: false })

    // Replaying the old signed URL only rewrites the (now unused) staging key.
    await putZip(prepared.data.bundle_upload.url, bundleZip('evil(); notifyAppReady()'))
    expect(await liveBundleHash('12.0.0')).toBe(sha256(zip))
    expect((await api('complete', token)).data.error).toBe('upload_already_completed')
  })

  it('refuses a concurrent prepare of the same version instead of overwriting it', async () => {
    const results = await Promise.all([prepare('13.0.0', { channel: 'upload-session-race' }), prepare('13.0.0', { channel: 'upload-session-race' })])
    expect(results.map(result => result.status).sort()).toEqual([200, 409])
    const winner = results.find(result => result.status === 200)!
    expect(results.find(result => result.status === 409)!.data.error).toBe('version_already_exists')
    expect((await versionRow('13.0.0'))?.id).toBe(winner.data.version_id)
    expect((await api('abort', { upload_token: winner.data.upload_token })).status).toBe(200)
  })

  it('re-checks compatibility when the channel changed between prepare and complete', async () => {
    const camera = { name: '@capacitor/camera', version: '6.0.0' }
    const channel = 'upload-session-recheck'
    async function upload(version: string, nativePackages: unknown[], overrides: Record<string, unknown> = {}) {
      const prepared = await prepare(version, { channel, native_packages: nativePackages, ...overrides })
      expect(prepared.status).toBe(200)
      await putZip(prepared.data.bundle_upload.url, bundleZip(`notifyAppReady(); // ${version}`))
      return prepared
    }

    const base = await upload('11.0.0', [camera])
    expect((await api('complete', { upload_token: base.data.upload_token })).status).toBe(200)

    // Compatible with 11.0.0 at prepare time.
    const pending = await upload('11.0.1', [camera], { fail_on_incompatible: true })
    expect(pending.data.channels).toEqual([expect.objectContaining({ name: channel, compatibility: 'compatible', current_version: '11.0.0' })])

    // Meanwhile the channel moves to a bundle built against another native plugin version.
    const moved = await upload('11.0.2', [{ ...camera, version: '7.0.0' }])
    expect((await api('complete', { upload_token: moved.data.upload_token })).status).toBe(200)

    const completed = await api('complete', { upload_token: pending.data.upload_token })
    expect(completed.status).toBe(400)
    expect(completed.data.error).toBe('incompatible_bundle')
    expect(await versionRow('11.0.1')).toBeNull()
    const { data: channelRow } = await getSupabaseClient().from('channels').select('version').eq('app_id', APPNAME).eq('name', channel).single()
    expect(channelRow?.version).toBe((await versionRow('11.0.2'))?.id)
  })

  it('sets a progressive rollout on a channel with a stable bundle', async () => {
    const conflict = await prepare('8.0.0', { rollout_percentage: 10, delete_linked_bundle_on_upload: true })
    expect(conflict.data.error).toBe('invalid_options')

    const missingStable = await prepare('8.0.0', { rollout_percentage: 10, channel: 'upload-session-no-stable' })
    expect(missingStable.data.error).toBe('rollout_requires_stable_bundle')

    const prepared = await prepare('8.0.0', { rollout_percentage: 10 })
    expect(prepared.status).toBe(200)
    await putZip(prepared.data.bundle_upload.url, bundleZip('notifyAppReady(); // 8'))
    const completed = await api('complete', { upload_token: prepared.data.upload_token })
    expect(completed.status).toBe(200)
    expect(completed.data.channels_updated).toEqual(['production'])
    const { data: channel } = await getSupabaseClient().from('channels').select('version, rollout_version, rollout_percentage_bps, rollout_enabled').eq('app_id', APPNAME).eq('name', 'production').single()
    const stable = await versionRow('2.0.0')
    const rolled = await versionRow('8.0.0')
    expect(channel).toMatchObject({ version: stable?.id, rollout_version: rolled?.id, rollout_percentage_bps: 1000, rollout_enabled: true })
  })
})