Ctrl K
ComunidadGuías…

tests/bundle-upload-session.unit.test.ts

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

Archivo original ↗Preguntar a KiBot
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import { brotliDecompressSync, deflateRawSync } from 'node:zlib'
import AdmZip from 'adm-zip'
import { describe, expect, it } from 'vitest'
import { isUploadRejection } from '../supabase/functions/_backend/public/bundle/upload_session.ts'
import { BundleUploadTokenError, signBundleUploadToken, verifyBundleUploadToken } from '../supabase/functions/_backend/utils/bundle_upload_token.ts'
import { quickError, simpleError } from '../supabase/functions/_backend/utils/hono.ts'
import {
  BROTLI_MIN_SIZE,
  BundleZipError,
  checksumAlgorithmForUpdater,
  compressDeltaFile,
  containsAscii,
  Crc32,
  entryDataFromBuffer,
  extractDeltaPack,
  findCentralDirectory,
  hasRootIndexHtml,
  inflateEntry,
  isJsEntry,
  MAX_CENTRAL_DIRECTORY_BYTES,
  parseCentralDirectory,
  parseDeltaPackManifest,
  readZipDirectory,
  selectDeltaEntries,
  updaterSupportsDelta,
  walkZipStream,
  ZIP_TAIL_READ_SIZE,
} from '../supabase/functions/_backend/utils/bundle_zip.ts'

const SECRET = 'unit-test-secret'

function buildZip(files: Record<string, string | Buffer>): Buffer {
  const zip = new AdmZip()
  for (const [name, content] of Object.entries(files))
    zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content))
  return zip.toBuffer()
}

function chunkedStream(bytes: Uint8Array, chunkSize: number): ReadableStream<Uint8Array> {
  let offset = 0
  return new ReadableStream({
    pull(controller) {
      if (offset >= bytes.byteLength) {
        controller.close()
        return
      }
      controller.enqueue(bytes.slice(offset, offset + chunkSize))
      offset += chunkSize
    },
  })
}

function directoryOf(zip: Buffer) {
  const tail = zip.subarray(Math.max(0, zip.byteLength - ZIP_TAIL_READ_SIZE))
  const location = findCentralDirectory(tail, zip.byteLength)
  return parseCentralDirectory(zip.subarray(location.centralDirectoryOffset, location.centralDirectoryOffset + location.centralDirectorySize), location.entryCount)
}

describe('bundle upload token', () => {
  it('round-trips signed claims', async () => {
    const { token, expiresAt } = await signBundleUploadToken(SECRET, { version_id: 42, app_id: 'com.test.app' }, 1_000)
    expect(expiresAt).toBe(1_000 + 3600)
    const claims = await verifyBundleUploadToken<{ version_id: number, app_id: string }>(SECRET, token, 1_500)
    expect(claims.version_id).toBe(42)
    expect(claims.app_id).toBe('com.test.app')
  })

  it('rejects tampered payloads, wrong secrets and expired tokens', async () => {
    const { token } = await signBundleUploadToken(SECRET, { version_id: 42 }, 1_000)
    const [prefix, body, signature] = token.split('.')
    const forgedBody = Buffer.from(JSON.stringify({ version_id: 43, iat: 1_000, exp: 4_600 })).toString('base64url')
    await expect(verifyBundleUploadToken(SECRET, `${prefix}.${forgedBody}.${signature}`, 1_500)).rejects.toMatchObject({ reason: 'signature' })
    await expect(verifyBundleUploadToken('other-secret', token, 1_500)).rejects.toMatchObject({ reason: 'signature' })
    await expect(verifyBundleUploadToken(SECRET, token, 4_600)).rejects.toMatchObject({ reason: 'expired' })
    await expect(verifyBundleUploadToken(SECRET, `${prefix}.${body}`, 1_500)).rejects.toBeInstanceOf(BundleUploadTokenError)
    await expect(verifyBundleUploadToken(SECRET, 12, 1_500)).rejects.toMatchObject({ reason: 'malformed' })
  })

  it('accepts expired tokens when expiry is ignored (abort path)', async () => {
    const { token } = await signBundleUploadToken(SECRET, { version_id: 7 }, 1_000)
    await expect(verifyBundleUploadToken(SECRET, token, 0)).resolves.toMatchObject({ version_id: 7 })
  })
})

describe('updater version rules (CLI parity)', () => {
  it('picks the checksum algorithm like prepareBundleFile', () => {
    expect(checksumAlgorithmForUpdater('7.0.30')).toBe('sha256')
    expect(checksumAlgorithmForUpdater('7.0.29')).toBe('crc32')
    expect(checksumAlgorithmForUpdater('6.25.0')).toBe('sha256')
    expect(checksumAlgorithmForUpdater('6.24.9')).toBe('crc32')
    expect(checksumAlgorithmForUpdater('5.10.0')).toBe('sha256')
    expect(checksumAlgorithmForUpdater('5.9.0')).toBe('crc32')
    expect(checksumAlgorithmForUpdater('8.0.0')).toBe('sha256')
    expect(checksumAlgorithmForUpdater('link:@capgo/capacitor-updater')).toBe('sha256')
    expect(checksumAlgorithmForUpdater('^7.1.0')).toBe('crc32')
    expect(checksumAlgorithmForUpdater('1.0.0', '@capgo/cordova-updater')).toBe('sha256')
  })

  it('gates delta uploads on Brotli-capable updaters', () => {
    expect(updaterSupportsDelta('7.0.30')).toBe(true)
    expect(updaterSupportsDelta('7.0.29')).toBe(false)
    expect(updaterSupportsDelta('6.25.0')).toBe(true)
    expect(updaterSupportsDelta('5.9.9')).toBe(false)
    expect(updaterSupportsDelta(undefined)).toBe(false)
    expect(updaterSupportsDelta('not-a-version')).toBe(false)
  })
})

describe('checksums', () => {
  it('computes the CLI crc32 incrementally', () => {
    const crc = new Crc32()
    crc.update(Buffer.from('1234'))
    crc.update(Buffer.from('56789'))
    expect(crc.digestHex()).toBe('cbf43926')
  })
})

describe('zip inspection', () => {
  it('reads an AdmZip archive (the CLI zipper) and finds index.html and notifyAppReady', async () => {
    const js = `console.log("x");${'a'.repeat(20_000)};CapacitorUpdater.notifyAppReady()`
    const zip = buildZip({ 'index.html': '<html></html>', 'assets/app.js': js, 'assets/empty.txt': '' })
    const entries = directoryOf(zip)
    expect(entries.map(entry => entry.name).sort()).toEqual(['assets/app.js', 'assets/empty.txt', 'index.html'])
    expect(hasRootIndexHtml(entries)).toBe(true)

    const hash = createHash('sha256')
    let found = false
    await walkZipStream(chunkedStream(zip, 97), entries, isJsEntry, async (entry, compressed) => {
      found = containsAscii(await inflateEntry(entry, compressed), 'notifyAppReady')
    }, bytes => hash.update(bytes))
    expect(found).toBe(true)
    expect(hash.digest('hex')).toBe(createHash('sha256').update(zip).digest('hex'))
  })

  it('requires index.html at the zip root, like checkIndexPosition', () => {
    const zip = buildZip({ 'dist/index.html': '<html></html>' })
    expect(hasRootIndexHtml(directoryOf(zip))).toBe(false)
  })

  it('rejects non-zip data', () => {
    expect(() => findCentralDirectory(new Uint8Array(100), 100)).toThrow(BundleZipError)
  })

  it('selects delta files like generateManifest (no empty, ignored or directory entries)', () => {
    const zip = new AdmZip()
    zip.addFile('index.html', Buffer.from('<html></html>'))
    zip.addFile('empty.js', Buffer.alloc(0))
    zip.addFile('.DS_Store', Buffer.from('x'))
    zip.addFile('node_modules/x.js', Buffer.from('x'))
    zip.addFile('folder/', Buffer.alloc(0))
    const names = selectDeltaEntries(directoryOf(zip.toBuffer())).map(entry => entry.name)
    expect(names).toEqual(['index.html'])
  })
})

describe('delta file compression', () => {
  it('keeps small or incompressible files uncompressed', () => {
    const small = Buffer.from('a'.repeat(BROTLI_MIN_SIZE - 1))
    expect(compressDeltaFile(small).compression).toBe('none')
    const random = createHash('sha512').update('seed').digest()
    const noisy = Buffer.concat(Array.from({ length: 200 }, (_, i) => createHash('sha512').update(random).update(String(i)).digest()))
    expect(compressDeltaFile(noisy).compression).toBe('none')
  })

  it('uses Brotli for compressible files at or above the RFC minimum', () => {
    const content = Buffer.from('const value = 1;\n'.repeat(1000))
    const result = compressDeltaFile(content)
    expect(result.compression).toBe('brotli')
    expect(Buffer.from(brotliDecompressSync(result.payload)).equals(content)).toBe(true)
  })
})

describe('delta pack', () => {
  const blob = Buffer.from('console.log(1)')
  const blobSha = createHash('sha256').update(blob).digest('hex')

  function packManifest(overrides: Record<string, unknown> = {}) {
    return {
      protocol_version: 1,
      file_hash_format: 'sha256_hex',
      delta_encryption: { enabled: false },
      entries: [{ id: 0, file_name: 'app.js', file_hash: blobSha, compression: 'none', uploaded_bytes_sha256: blobSha, uploaded_bytes_size: blob.byteLength, pack_path: 'files/0' }],
      ...overrides,
    }
  }

  it('parses the CLI packer manifest and reads its blobs', async () => {
    const zip = buildZip({ 'capgo-delta-manifest.json': JSON.stringify(packManifest()), 'files/0': blob })
    const entries = readZipDirectory(zip)
    const manifestEntry = entries.find(entry => entry.name === 'capgo-delta-manifest.json')!
    const parsed = parseDeltaPackManifest(JSON.parse(Buffer.from(await inflateEntry(manifestEntry, entryDataFromBuffer(zip, manifestEntry))).toString()))
    expect(parsed.entries[0]).toMatchObject({ id: 0, file_name: 'app.js', compression: 'none', pack_path: 'files/0' })
    const blobEntry = entries.find(entry => entry.name === 'files/0')!
    expect(Buffer.from(await inflateEntry(blobEntry, entryDataFromBuffer(zip, blobEntry))).equals(blob)).toBe(true)
  })

  it('accepts brotli entries and rejects invalid manifests', () => {
    const brotli = packManifest({ entries: [{ ...packManifest().entries[0], file_name: 'app.js.br', compression: 'brotli' }] })
    expect(parseDeltaPackManifest(brotli).entries[0]!.compression).toBe('brotli')
    expect(() => parseDeltaPackManifest(packManifest({ entries: [{ ...packManifest().entries[0], compression: 'br' }] }))).toThrow(BundleZipError)
    expect(() => parseDeltaPackManifest(packManifest({ entries: [{ ...packManifest().entries[0], id: undefined }] }))).toThrow(BundleZipError)
    expect(() => parseDeltaPackManifest(packManifest({ protocol_version: 2 }))).toThrow(BundleZipError)
    expect(() => parseDeltaPackManifest(packManifest({ entries: [] }))).toThrow(BundleZipError)
    expect(() => parseDeltaPackManifest(packManifest({ entries: [{ ...packManifest().entries[0], pack_path: '../escape' }] }))).toThrow(BundleZipError)
    const duplicate = packManifest().entries[0]
    expect(() => parseDeltaPackManifest(packManifest({ entries: [duplicate, { ...duplicate, id: 1, file_name: 'b.js' }] }))).toThrow(BundleZipError)
    expect(() => parseDeltaPackManifest(packManifest({ entries: [duplicate, { ...duplicate, file_name: 'b.js', pack_path: 'files/1' }] }))).toThrow(BundleZipError)
  })
})

describe('zip memory bounds', () => {
  function eocd(entryCount: number, directorySize: number, directoryOffset: number) {
    const tail = Buffer.alloc(22)
    tail.writeUInt32LE(0x06054B50, 0)
    tail.writeUInt16LE(entryCount, 8)
    tail.writeUInt16LE(entryCount, 10)
    tail.writeUInt32LE(directorySize, 12)
    tail.writeUInt32LE(directoryOffset, 16)
    return tail
  }

  function entry(overrides: Partial<ZipEntryLike> = {}): ZipEntryLike {
    return { name: 'a.js', method: 8, flags: 0, crc32: 0, compressedSize: 10, uncompressedSize: 10, localHeaderOffset: 0, isDirectory: false, ...overrides }
  }
  type ZipEntryLike = Parameters<typeof inflateEntry>[0]

  it('caps the central directory size before reading it', () => {
    const size = MAX_CENTRAL_DIRECTORY_BYTES + 1
    expect(() => findCentralDirectory(eocd(1, size, 0), size + 22)).toThrow(expect.objectContaining({ code: 'zip_directory_too_large' }))
  })

  it('rejects an entry count that cannot fit the central directory', () => {
    expect(() => findCentralDirectory(eocd(1000, 100, 0), 122)).toThrow(expect.objectContaining({ code: 'invalid_zip' }))
  })

  it('refuses entries above the inflate cap without allocating them', async () => {
    await expect(inflateEntry(entry({ uncompressedSize: 0xFFFFFFF0, compressedSize: 100 }), new Uint8Array(100))).rejects.toMatchObject({ code: 'zip_entry_too_large' })
    await expect(inflateEntry(entry({ uncompressedSize: 2000, compressedSize: 100 }), new Uint8Array(100), 1000)).rejects.toMatchObject({ code: 'zip_entry_too_large' })
  })

  it('rejects implausible compressed sizes', async () => {
    await expect(inflateEntry(entry({ uncompressedSize: 10, compressedSize: 5000 }), new Uint8Array(5000))).rejects.toMatchObject({ code: 'invalid_zip' })
    await expect(inflateEntry(entry({ method: 0, uncompressedSize: 10, compressedSize: 11 }), new Uint8Array(11))).rejects.toMatchObject({ code: 'invalid_zip' })
  })

  it('stops inflating past the declared size (zip bomb with a lying header)', async () => {
    const real = Buffer.alloc(100_000, 0x61)
    const compressed = deflateRawSync(real)
    await expect(inflateEntry(entry({ uncompressedSize: 1000, compressedSize: compressed.byteLength }), compressed)).rejects.toMatchObject({ code: 'invalid_zip' })
    const ok = await inflateEntry(entry({ uncompressedSize: real.byteLength, compressedSize: compressed.byteLength }), compressed)
    expect(Buffer.from(ok).equals(real)).toBe(true)
  })

  it('checks wanted entries before buffering them from the stream', async () => {
    const zip = buildZip({ 'index.html': '<html></html>', 'big.js': 'x'.repeat(5000) })
    const entries = directoryOf(zip)
    let called = false
    await expect(walkZipStream(chunkedStream(zip, 64), entries, isJsEntry, () => {
      called = true
    }, () => undefined, 1000)).rejects.toMatchObject({ code: 'zip_entry_too_large' })
    expect(called).toBe(false)
  })
})

describe('delta pack extraction budgets', () => {
  const files = { 'app.js': Buffer.from('notifyAppReady()'), 'index.html': Buffer.from('<html></html>') }

  function pack(options: { lieSize?: boolean } = {}) {
    const zip = new AdmZip()
    const entries = Object.entries(files).map(([fileName, bytes], index) => {
      zip.addFile(`files/${index}`, bytes)
      const sha = createHash('sha256').update(bytes).digest('hex')
      return { id: index, file_name: fileName, compression: 'none', file_hash: sha, uploaded_bytes_sha256: sha, uploaded_bytes_size: bytes.byteLength + (options.lieSize && index === 0 ? 1 : 0), 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 new Uint8Array(zip.toBuffer())
  }
  const limits = { hasSessionKey: false, maxEntries: 10, maxPayloadBytes: 1024 }

  it('extracts a valid pack', async () => {
    const { files: extracted } = await extractDeltaPack(pack(), limits)
    expect(extracted.map(file => file.file_name)).toEqual(['app.js', 'index.html'])
  })

  it('enforces the total payload budget before inflating', async () => {
    await expect(extractDeltaPack(pack(), { ...limits, maxPayloadBytes: 20 })).rejects.toMatchObject({ code: 'delta_too_large_for_server' })
  })

  it('enforces the entry count and manifest size', async () => {
    await expect(extractDeltaPack(pack(), { ...limits, maxEntries: 1 })).rejects.toMatchObject({ code: 'delta_too_large_for_server' })
    await expect(extractDeltaPack(pack(), { ...limits, maxManifestBytes: 16 })).rejects.toMatchObject({ code: 'delta_pack_invalid' })
  })

  it('rejects blobs whose zip size disagrees with the manifest', async () => {
    await expect(extractDeltaPack(pack({ lieSize: true }), limits)).rejects.toMatchObject({ code: 'delta_pack_invalid' })
  })

  it('rejects truncated archives', async () => {
    const bytes = pack()
    await expect(extractDeltaPack(bytes.subarray(0, bytes.byteLength - 10), limits)).rejects.toBeInstanceOf(BundleZipError)
  })
})

describe('upload rejection classification', () => {
  function caught(fn: () => unknown): unknown {
    try {
      return fn()
    }
    catch (error) {
      return error
    }
  }

  it('only treats deterministic content rejections as permanent', () => {
    expect(isUploadRejection(caught(() => simpleError('checksum_mismatch', 'x')))).toBe(true)
    expect(isUploadRejection(caught(() => simpleError('incompatible_bundle', 'x')))).toBe(true)
    expect(isUploadRejection(caught(() => quickError(413, 'error_manifest_too_large', 'x')))).toBe(true)
    expect(isUploadRejection(caught(() => quickError(409, 'upload_missing', 'x')))).toBe(false)
    expect(isUploadRejection(caught(() => quickError(409, 'error_manifest_inventory_conflict', 'x')))).toBe(false)
    expect(isUploadRejection(caught(() => simpleError('cannot_check_channel', 'x')))).toBe(false)
    expect(isUploadRejection(caught(() => simpleError('cannot_load_channel', 'x')))).toBe(false)
    expect(isUploadRejection(caught(() => quickError(403, 'permission_denied', 'x')))).toBe(false)
    expect(isUploadRejection(caught(() => quickError(503, 'storage_unavailable', 'x')))).toBe(false)
    expect(isUploadRejection(new Error('boom'))).toBe(false)
  })
})