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 = {}) { const zip = new AdmZip() zip.addFile('index.html', Buffer.from('')) 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 = {}) { 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 = {}) { 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': '', '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 = {}) { 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 }) }) })