[
  {
    "path": ".gitattributes",
    "content": "*.js eol=lf\n*.json eol=lf\n*.yml eol=lf\n*.md eol=lf\n*.xml eol=lf"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [simonnilsson]\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: ci\n\non: [push, pull_request]\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n      - run: npm ci\n      - run: npm run lint\n\n  test:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n        node-version: [18, 20, 22]\n    steps:\n      - uses: actions/checkout@v4\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n      - run: npm ci\n      - run: npm test\n\n  coveralls:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n      - run: npm ci\n      - run: npm run coverage\n      - uses: coverallsapp/github-action@v2\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: release\n\non:\n  release:\n    types: [published]\n\njobs:\n  publish-npm:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          registry-url: https://registry.npmjs.org/\n      - run: npm ci\n      - run: npm publish\n        env:\n          NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}\n\n  upload-binaries:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n      - run: npm ci\n      - run: npm run build\n      - uses: AButler/upload-release-assets@v3.0\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n          files: \"build/ios-uploader-*\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# General\n.vscode\n.DS_Store\nbuild/\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n.env.test\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\nAll notable changes to this project will be documented in this file.\n\n## [Unreleased]\n\n\n## [3.0.3] - 2025-04-21\n### Changed\n- Updated dependencies\n\n\n## [3.0.2] - 2025-02-27\n### Changed\n- Updated dependencies\n- Added Node v22 to CI tests\n\n\n## [3.0.1] - 2024-06-25\n### Changed\n- Updated dependencies\n- Switched from vercel/pkg to yao-pkg/pkg as vercel has deprecated pkg\n- Updated to ESLint v9 and added formatting rules using @stylistic/eslint-plugin\n\n\n## [3.0.0] - 2024-02-24\n### Changed\n- **BREAKING** Dropped support for Node versions below v18\n- Bump binary releases from Node v14 to v18\n- Added Node v20 to CI tests\n- Updated dependencies\n\n\n## [2.2.2] - 2023-02-08\n### Changed\n- Downgrade to axios@0.27.2 to fix pkg builds\n\n\n## [2.2.1] - 2023-01-21\n### Changed\n- Updated dependencies\n- Added Node v18 to CI workflow\n\n\n## [2.2.0] - 2022-06-22\n### Changed\n- Reduced size of progress indicator\n- Updated dependencies\n\n\n## [2.1.1] - 2022-04-18\n### Changed\n- Updated dependencies\n\n\n## [2.1.0] - 2022-03-27\n### Fixed\n- Correctly support http:// URLs.\n- Add FTP support to help output\n\n### Changed\n- Progress bar improvements\n- Updated dependencies\n\n\n## [2.0.2] - 2022-02-17\n### Changed\n- Updated dependencies\n\n## [2.0.1] - 2022-02-05\n### Changed\n- Updated dependencies\n\n\n## [2.0.0] - 2022-01-18\n### Changed\n- **BREAKING** Dropped support for Node v10\n- Bump binary releases from Node v12 to v14\n- Updated dependencies\n\n\n## [1.5.2] - 2021-11-14\n### Changed\n- Updated dependencies\n\n\n## [1.5.1] - 2021-09-24\n### Changed\n- Updated dependencies\n\n\n## [1.5.0] - 2021-08-27\n### Added\n- Added support for HTTP/HTTPS URLs to .ipa #16\n\n### Fixed\n- Fixed Coveralls badge link\n\n### Changed\n- Updated dependencies\n\n\n## [1.4.0] - 2021-06-27\n### Fixed\n- Rework of bundle info lookup to solve issues with some IPA-files.\n- Improved error message when bundle info lookup fails\n\n### Changed\n- Updated dependencies\n\n\n## [1.3.0] - 2021-05-11\n### Fixed\n- Limit what files get published to npm\n\n### Changed\n- Updated dependencies\n- Renamed npm token in build\n- Added Node v16 to CI tests\n- Binary releases now bundle Node v12\n\n\n## [1.2.3] - 2021-04-30\n### Fixed\n- Minor README fixes\n\n### Changed\n- Updated dependencies\n- Moved CI to Github Actions\n\n\n## [1.2.2] - 2021-03-27\n### Changed\n- Updated dependencies\n\n\n## [1.2.1] - 2021-01-16\n### Fixed\n- Catch error thrown by plist parsing #9\n- Make Info.plist regex more specific #9\n- Fix upload of big application archives #7\n\n### Changed\n- Updated dependencies\n\n\n## [1.2.0] - 2020-12-29\n### Fixed\n- Fixed error handling on failed upload #7\n\n### Changed\n- Updated dependencies\n\n\n## [1.1.3] - 2020-11-06\n### Added\n- Added CHANGELOG.md\n\n### Fixed\n- Fixed invalid code syntax and indentation\n- Change spelling of \"Licence\" to \"License\" in README.md\n\n### Changed\n- Updated dependencies\n\n\n## [1.1.2] - 2020-08-31\n### Added\n- Added version validation #4\n\n### Changed\n- Help message improvements #6 \n\n\n## [1.1.1] - 2020-08-27\n### Added\n- Include bundle info in validateAssets #4\n\n### Fixed\n- Fixed incorrect short version in metadata #5\n\n### Changed\n- Improved info messages\n\n\n## [1.1.0] - 2020-08-26\n### Added\n- Added sanitation of input file name\n- Added version validation #4\n\n### Fixed\n-  Fixed typo\n\n### Changed\n- Improved error reporting #3\n\n### Removed\n- Removed support for bundle-id argument\n\n\n## [1.0.2] - 2020-08-25\n### Added\n- Added gitattributes file\n\n### Fixed\n- Travis CI fixes\n\n\n## [1.0.1] - 2020-08-20\n### Changed\n- Updated dependencies\n\n### Fixed\n- Travis CI fixes\n- Time issue in tests\n\n\n## [1.0.0] - 2020-07-02\n- Initial release\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2020 Simon Nilsson\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "README.md",
    "content": "# ios-uploader\n\n[![npm](https://img.shields.io/npm/v/ios-uploader.svg?style=flat-square)](https://www.npmjs.org/package/ios-uploader)\n[![build](https://github.com/simonnilsson/ios-uploader/workflows/ci/badge.svg)](https://github.com/simonnilsson/ios-uploader/actions?query=workflow%3Aci+branch%3Amain)\n[![coverage](https://coveralls.io/repos/github/simonnilsson/ios-uploader/badge.svg?branch=main)](https://coveralls.io/github/simonnilsson/ios-uploader?branch=main)\n[![install size](https://packagephobia.com/badge?p=ios-uploader)](https://packagephobia.com/result?p=ios-uploader)\n[![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://github.com/vsouza/awesome-ios)\n\nEasy to use, cross-platform tool to upload iOS apps to App Store Connect.\n\n## Installation\n\n### System Requirements\n* **OS**: Windows, macOS or Linux\n* **Node.js**: v18 or newer (bundled with standalone binaries)\n\nIf you have Node.js and npm installed the simplest way is to just install the package globally. The tool will automatically be added to your PATH as `ios-uploader`.\n\n```sh\nnpm install -g ios-uploader\n```\n\nThe program is also available as standalone binaries for all major OS:es on [github.com](https://github.com/simonnilsson/ios-uploader/releases).\n\n## Usage\n\nIf you have used `altool` previously to upload applications the process should be very familiar.\n\n```sh\n$ ios-uploader -u <username> -p <password> -f <path/to/app.ipa>\n```\n\nis equivalent to the following command using altool (macOS only):\n\n```sh\n$ xcrun altool --upload-app -u <username> -p <password> -f <path/to/app.ipa>\n```\n\n> See this page for information on how to generate an app specific password: <br>https://support.apple.com/en-us/HT204397\n\n## Options\n\n```\n  -v, --version               output the current version and exit\n  -u, --username <string>     your Apple ID\n  -p, --password <string>     app-specific password for your Apple ID\n  -f, --file <string>         path to .ipa file for upload (local file, http(s):// or ftp:// URL)\n  -c, --concurrency <number>  number of concurrent upload tasks to use (default: 4)\n  -h, --help                  output this help message and exit\n```\n\n## Disclaimer\n\nThis package is not endorsed by or in any way associated with Apple Inc. It is provided as is without warranty of any kind. The program may stop working at any time without prior notice if Apple decides to change the API.\n\n## License\n\n[MIT](LICENSE)"
  },
  {
    "path": "assets/metadata_template.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://apple.com/itunes/importer\" version=\"software5.4\">\n  <software_assets apple_id=\"APPLE_ID\" bundle_short_version_string=\"BUNDLE_SHORT_VERSION\" bundle_version=\"BUNDLE_VERSION\" bundle_identifier=\"BUNDLE_IDENTIFIER\" app_platform=\"ios\">\n    <asset type=\"bundle\">\n      <data_file>\n        <size>FILE_SIZE</size>\n        <file_name>FILE_NAME</file_name>\n        <checksum type=\"md5\">MD5</checksum>\n      </data_file>\n    </asset>\n  </software_assets>\n</package>"
  },
  {
    "path": "bin/cli.js",
    "content": "#!/usr/bin/env node\n\nconst { queue } = require('async');\nconst { Command } = require('commander');\nconst cliProgress = require('cli-progress');\nconst prettyBytes = require('pretty-bytes');\nconst { version, name } = require('../package');\n\nconst utility = require('../lib/utility');\nconst api = require('../lib/index');\n\nconst cli = new Command()\n  .version(version, '-v, --version', 'output the current version and exit')\n  .name(name)\n  .usage('-u <username> -p <password> -f <file> [additional-options]')\n  .helpOption('-h, --help', 'output this help message and exit')\n  .requiredOption('-u, --username <string>', 'your Apple ID')\n  .requiredOption('-p, --password <string>', 'app-specific password for your Apple ID')\n  .requiredOption('-f, --file <string>', 'path to .ipa file for upload (local file, http(s):// or ftp:// URL)')\n  .option('-c, --concurrency <number>', 'number of concurrent upload tasks to use', 4);\n\nconst fileUrlRegex = /^(?:https?|ftp):\\/\\//;\n\nfunction formatValue(v, options, type) {\n  switch (type) {\n    case 'value':\n    case 'total':\n      return prettyBytes(v);\n    default:\n      return v;\n  }\n}\n\nasync function runUpload(ctx) {\n  let exitCode = 0;\n\n  const progressBar = new cliProgress.Bar({\n    format: '{task} |{bar}| {percentage}% | {value} / {total} | {speed}',\n    hideCursor: true,\n    barsize: 20,\n    formatValue,\n  }, cliProgress.Presets.shades_classic);\n\n  try {\n    // Handle URLs to ipa file.\n    if (fileUrlRegex.test(ctx.filePath)) {\n      ctx.originalFilePath = ctx.filePath;\n      try {\n        const transferStartTime = Date.now();\n        let started = false;\n        ctx.filePath = await utility.downloadTempFile(ctx.filePath, (current, total) => {\n          let { speed, eta } = utility.formatSpeedAndEta(current, total, Date.now() - transferStartTime);\n          !started\n            ? progressBar.start(total, current, { task: 'Downloading', speed, etas: eta })\n            : progressBar.update(current, { speed, etas: eta });\n          started = true;\n        });\n        progressBar.stop();\n      }\n      catch (err) {\n        throw new Error(`Could not download file: ${err.message}`);\n      }\n      ctx.usingTempFile = true;\n    }\n\n    // Open the application file for reading.\n    ctx.fileHandle = await utility.openFile(ctx.filePath);\n\n    // Bundle ID and version lookup.\n    try {\n      let extracted = await utility.extractBundleIdAndVersion(ctx.fileHandle);\n      ctx.bundleId = extracted.bundleId;\n      ctx.bundleVersion = extracted.bundleVersion;\n      ctx.bundleShortVersion = extracted.bundleShortVersion;\n      console.log(`Found Bundle ID \"${ctx.bundleId}\", Version ${ctx.bundleVersion} (${ctx.bundleShortVersion}).`);\n    }\n    catch (err) {\n      console.error(err.message);\n      throw new Error('Failed to extract Bundle ID and version, are you supplying a valid IPA-file?');\n    }\n\n    // Authenticate with Apple.\n    await api.authenticateForSession(ctx);\n\n    // Find \"Apple ID\" of application.\n    await api.lookupSoftwareForBundleId(ctx);\n\n    console.log(`Identified application as \"${ctx.appName}\" (${ctx.appleId}).`);\n\n    // Generate metadata.\n    await api.generateMetadata(ctx);\n\n    // Validate metadata and assets.\n    await api.validateMetadata(ctx);\n    await api.validateAssets(ctx);\n    await api.clientChecksumCompleted(ctx);\n\n    // Make reservations for uploading.\n    let reservations = await api.createReservation(ctx);\n\n    // For time calculations.\n    ctx.transferStartTime = Date.now();\n    ctx.bytesSent = 0;\n\n    progressBar.start(ctx.metadataSize + ctx.fileSize, 0, { task: 'Uploading', speed: 'N/A', etas: 'N/A' });\n\n    let q = queue(api.executeOperation, ctx.concurrency);\n\n    // Start uploading.\n    for (let reservation of reservations) {\n      let tasks = reservation.operations.map((operation) => ({ ctx, reservation, operation }));\n      q.push(tasks, () => {\n        let { speed, eta } = utility.formatSpeedAndEta(ctx.bytesSent, ctx.metadataSize + ctx.fileSize, Date.now() - ctx.transferStartTime);\n        progressBar.update(ctx.bytesSent, { speed, etas: eta });\n      });\n      await Promise.race([q.drain(), q.error()]);\n      await api.commitReservation(ctx, reservation);\n    }\n\n    // Calculate transfer time.\n    ctx.transferTime = ctx.transferStartTime - Date.now();\n\n    // Finish\n    await api.uploadDoneWithArguments(ctx);\n\n    progressBar.stop();\n    console.log('The cookies are done.');\n  }\n  catch (err) {\n    progressBar.stop();\n    console.error(err.message);\n    exitCode = 1;\n  }\n  finally {\n    if (ctx.fileHandle) {\n      await utility.closeFile(ctx.fileHandle);\n    }\n    if (ctx.usingTempFile) {\n      await utility.removeTempFile(ctx.filePath);\n    }\n  }\n\n  process.exit(exitCode);\n}\n\nasync function run() {\n  // Parse command line params\n  cli.parse(process.argv);\n\n  const options = cli.opts();\n\n  // Context variable keeping track of all the necessary information for upload procedure.\n  const ctx = {\n    username: options.username,\n    password: options.password,\n    filePath: options.file,\n    concurrency: options.concurrency,\n    packageName: 'app.itmsp',\n  };\n\n  await runUpload(ctx);\n}\n\nfunction stop(signal) {\n  // Fix to make sure cursor gets restored to visible state when exiting mid progress.\n  process.stderr.write('\\u001B[?25h');\n\n  process.exit(128 + signal);\n}\n\n// Run only if called directly (e.g. not when tested)\nif (require.main === module) {\n  process.on('SIGINT', () => stop(2));\n  process.on('SIGTERM', () => stop(15));\n  run();\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import js from '@eslint/js';\r\nimport globals from 'globals';\r\nimport stylistic from '@stylistic/eslint-plugin';\r\nimport jsdoc from 'eslint-plugin-jsdoc';\r\n\r\nexport default [\r\n  js.configs.recommended,\r\n  stylistic.configs.customize({\r\n    indent: 2,\r\n    quotes: 'single',\r\n    quoteProps: 'as-needed',\r\n    arrowParens: true,\r\n    semi: true,\r\n  }),\r\n  {\r\n    files: ['**/*.js'],\r\n    plugins: {\r\n      jsdoc,\r\n    },\r\n    rules: {\r\n      'jsdoc/no-undefined-types': 1,\r\n    },\r\n    languageOptions: {\r\n      ecmaVersion: 2022,\r\n      sourceType: 'module',\r\n      globals: {\r\n        ...globals.node,\r\n        ...globals.mocha,\r\n      },\r\n    },\r\n  },\r\n];\r\n"
  },
  {
    "path": "lib/index.js",
    "content": "const axios = require('axios');\nconst path = require('path');\n\nconst utility = require('./utility');\n\nconst SOFTWARE_SERVICE_URL = 'https://contentdelivery.itunes.apple.com/WebObjects/MZLabelService.woa/json/MZITunesSoftwareService';\nconst PRODUCER_SERVICE_URL = 'https://contentdelivery.itunes.apple.com/WebObjects/MZLabelService.woa/json/MZITunesProducerService';\nconst USER_AGENT = 'iTMSTransporter/2.0.0';\n\nconst MAX_BODY_LENGTH = 1024 ** 3;\n\n/**\n * Construct error message using application error string and response object.\n * @param {String} message Application error message\n * @param {Object|undefined} response Response object from remote request,\n * used to extract error message if any.\n * @returns {Error} An error that can be thrown.\n */\nfunction constructError(message, response) {\n  let errorMessage = message;\n  if (response && response.ErrorMessage) {\n    errorMessage += '\\n' + response.ErrorMessage;\n  }\n  return new Error(errorMessage);\n}\n\nasync function generateMetadata(ctx) {\n  let metaText = await utility.readFile(path.join(__dirname, '../assets/metadata_template.xml'));\n\n  const fileStats = await utility.getFileStats(ctx.fileHandle);\n  ctx.fileName = path.basename(ctx.filePath).replace(/[: ]/g, '_');\n  ctx.fileChecksum = await utility.getFileMD5(ctx.fileHandle);\n  ctx.fileSize = fileStats.size;\n  ctx.fileModifiedTime = Math.round(fileStats.mtimeMs);\n\n  metaText = metaText\n    .replace('APPLE_ID', ctx.appleId)\n    .replace('BUNDLE_SHORT_VERSION', ctx.bundleShortVersion)\n    .replace('BUNDLE_VERSION', ctx.bundleVersion)\n    .replace('BUNDLE_IDENTIFIER', ctx.bundleId)\n    .replace('FILE_SIZE', ctx.fileSize)\n    .replace('FILE_NAME', ctx.fileName)\n    .replace('MD5', ctx.fileChecksum);\n\n  ctx.metadataSize = metaText.length;\n  ctx.metadataChecksum = utility.getStringMD5(metaText);\n  ctx.metadataBuffer = Buffer.from(metaText, 'utf-8');\n  ctx.metadataCompressed = await utility.bufferToGZBase64(ctx.metadataBuffer);\n}\n\nasync function makeSoftwareServiceRequest(ctx, method, params) {\n  const requestId = utility.generateIDString();\n\n  const request = {\n    jsonrpc: '2.0',\n    method,\n    id: requestId,\n    params,\n  };\n\n  const headers = {\n    'User-Agent': USER_AGENT,\n    'Content-Type': 'application/json',\n  };\n\n  const json = JSON.stringify(request);\n  const jsonChecksum = utility.getStringMD5Buffer(json);\n\n  if (ctx.sessionId) {\n    headers['x-request-id'] = requestId;\n    headers['x-session-digest'] = utility.makeSessionDigest(ctx.sessionId, jsonChecksum, requestId, ctx.sharedSecret);\n    headers['x-session-id'] = ctx.sessionId;\n    headers['x-session-version'] = '2';\n  }\n\n  let res = await axios.post(\n    SOFTWARE_SERVICE_URL,\n    json,\n    { headers },\n  );\n\n  return res.data.result;\n}\n\nasync function makeProducerServiceRequest(ctx, method, params) {\n  const requestId = utility.generateIDString();\n\n  const request = {\n    jsonrpc: '2.0',\n    method,\n    id: requestId,\n    params,\n  };\n\n  const headers = {\n    'User-Agent': USER_AGENT,\n    'Content-Type': 'application/json',\n  };\n\n  const json = JSON.stringify(request);\n  const jsonChecksum = utility.getStringMD5Buffer(json);\n\n  if (ctx.sessionId) {\n    headers['x-request-id'] = requestId;\n    headers['x-session-digest'] = utility.makeSessionDigest(ctx.sessionId, jsonChecksum, requestId, ctx.sharedSecret);\n    headers['x-session-id'] = ctx.sessionId;\n    headers['x-session-version'] = '2';\n  }\n\n  let res = await axios.post(\n    PRODUCER_SERVICE_URL,\n    json,\n    { headers },\n  );\n\n  return res.data.result;\n}\n\nasync function authenticateForSession(ctx) {\n  let res = await makeProducerServiceRequest(ctx, 'authenticateForSession', {\n    Username: ctx.username,\n    Password: ctx.password,\n  });\n\n  if (res.SessionId && res.SharedSecret) {\n    ctx.sessionId = res.SessionId;\n    ctx.sharedSecret = res.SharedSecret;\n  }\n  else {\n    throw constructError('Authentication failed!', res);\n  }\n}\n\nasync function lookupSoftwareForBundleId(ctx) {\n  let res = await makeSoftwareServiceRequest(ctx, 'lookupSoftwareForBundleId', {\n    Application: 'altool',\n    ApplicationBundleId: 'com.apple.itunes.altool',\n    BundleId: ctx.bundleId,\n    Version: '4.0.1 (1182)',\n  });\n\n  if (!res.Success || res.Attributes.length < 1) {\n    throw constructError('Application lookup failed!', res);\n  }\n\n  ctx.appleId = res.Attributes[0].AppleID;\n  ctx.appName = res.Attributes[0].Application;\n  ctx.appIconUrl = res.Attributes[0].IconURL;\n}\n\nasync function validateMetadata(ctx) {\n  let res = await makeProducerServiceRequest(ctx, 'validateMetadata', {\n    Application: 'iTMSTransporter',\n    BaseVersion: '2.0.0',\n    Files: [\n      ctx.fileName,\n      'metadata.xml',\n    ],\n    iTMSTransporterMode: 'upload',\n    MetadataChecksum: ctx.metadataChecksum,\n    MetadataCompressed: ctx.metadataCompressed,\n    MetadataInfo: {\n      app_platform: 'ios',\n      apple_id: ctx.appleId,\n      asset_types: [\n        'bundle',\n      ],\n      bundle_identifier: ctx.bundleId,\n      bundle_short_version_string: ctx.bundleShortVersion,\n      bundle_version: ctx.bundleVersion,\n      device_id: '',\n      packageVersion: 'software5.4',\n      primary_bundle_identifier: '',\n    },\n    PackageName: ctx.packageName,\n    PackageSize: ctx.fileSize + ctx.metadataSize,\n    Username: ctx.username,\n    Version: '2.0.0',\n  });\n\n  if (!res.Success) {\n    throw constructError('Metadata validation failed!', res);\n  }\n}\n\nasync function validateAssets(ctx) {\n  let res = await makeProducerServiceRequest(ctx, 'validateAssets', {\n    Application: 'iTMSTransporter',\n    BaseVersion: '2.0.0',\n    AssetDescriptionsCompressed: [],\n    Files: [\n      ctx.fileName,\n      'metadata.xml',\n    ],\n    iTMSTransporterMode: 'upload',\n    MetadataChecksum: ctx.metadataChecksum,\n    MetadataCompressed: ctx.metadataCompressed,\n    MetadataInfo: {\n      app_platform: 'ios',\n      apple_id: ctx.appleId,\n      asset_types: [\n        'bundle',\n      ],\n      bundle_identifier: ctx.bundleId,\n      bundle_short_version_string: ctx.bundleShortVersion,\n      bundle_version: ctx.bundleVersion,\n      device_id: '',\n      packageVersion: 'software5.4',\n      primary_bundle_identifier: '',\n    },\n    PackageName: ctx.packageName,\n    PackageSize: ctx.fileSize + ctx.metadataSize,\n    StreamingInfoList: [],\n    Transport: 'HTTP',\n    Username: ctx.username,\n    Version: '2.0.0',\n  });\n\n  if (!res.Success) {\n    throw constructError('Asset validation failed!', res);\n  }\n\n  // validateAssets returns a new package name.\n  ctx.packageName = res.NewPackageName;\n}\n\nasync function clientChecksumCompleted(ctx) {\n  let res = await makeProducerServiceRequest(ctx, 'clientChecksumCompleted', {\n    Application: 'iTMSTransporter',\n    BaseVersion: '2.0.0',\n    iTMSTransporterMode: 'upload',\n    NewPackageName: ctx.packageName,\n    Username: ctx.username,\n    Version: '2.0.0',\n  });\n\n  if (!res.Success) {\n    throw constructError('Client checksum failed!', res);\n  }\n}\n\nasync function createReservation(ctx) {\n  let res = await makeProducerServiceRequest(ctx, 'createReservation', {\n    Application: 'iTMSTransporter',\n    BaseVersion: '2.0.0',\n    fileDescriptions: [\n      {\n        checksum: ctx.metadataChecksum,\n        checksumAlgorithm: 'MD5',\n        contentType: 'application/xml',\n        fileName: 'metadata.xml',\n        fileSize: ctx.metadataSize,\n      },\n      {\n        checksum: ctx.fileChecksum,\n        checksumAlgorithm: 'MD5',\n        contentType: 'application/octet-stream',\n        fileName: ctx.fileName,\n        fileSize: ctx.fileSize,\n        uti: 'com.apple.ipa',\n      },\n    ],\n    iTMSTransporterMode: 'upload',\n    NewPackageName: ctx.packageName,\n    Username: ctx.username,\n    Version: '2.0.0',\n  });\n\n  if (!res.Success) {\n    throw constructError('Create reservation failed!', res);\n  }\n\n  return res.Reservations;\n}\n\nasync function executeOperation({ ctx, reservation, operation }) {\n  let data;\n\n  if (reservation.file === 'metadata.xml') {\n    data = ctx.metadataBuffer.slice(operation.offset, operation.offset + operation.length);\n  }\n  else if (reservation.file === ctx.fileName) {\n    data = await utility.getFilePart(ctx.fileHandle, operation.offset, operation.length);\n  }\n  else {\n    // Unknown file\n    return;\n  }\n\n  let res;\n\n  try {\n    res = await axios({\n      url: operation.uri,\n      method: operation.method,\n      headers: Object.assign({\n        'User-Agent': USER_AGENT,\n      }, operation.headers),\n      validateStatus: null,\n      maxBodyLength: MAX_BODY_LENGTH,\n      data,\n    });\n  }\n  catch (err) {\n    throw new Error('Upload failed!\\n' + err.message);\n  }\n\n  if (res.status != 200) {\n    throw new Error('Upload failed! (' + res.status + ')');\n  }\n\n  ctx.bytesSent += operation.length;\n}\n\nasync function commitReservation(ctx, reservation) {\n  let res = await makeProducerServiceRequest(ctx, 'commitReservation', {\n    Application: 'iTMSTransporter',\n    BaseVersion: '2.0.0',\n    iTMSTransporterMode: 'upload',\n    NewPackageName: ctx.packageName,\n    reservations: [\n      reservation.id,\n    ],\n    Username: ctx.username,\n    Version: '2.0.0',\n  });\n\n  if (!res.Success) {\n    throw constructError('Commit reservation failed!', res);\n  }\n}\n\nasync function uploadDoneWithArguments(ctx) {\n  let res = await makeProducerServiceRequest(ctx, 'uploadDoneWithArguments', {\n    Application: 'iTMSTransporter',\n    BaseVersion: '2.0.0',\n    FileSizeInfo: {\n      [ctx.fileName]: ctx.fileSize,\n      'metadata.xml': ctx.metadataSize,\n    },\n    ClientChecksumInfo: [\n      {\n        CalculatedChecksum: ctx.fileChecksum,\n        CalculationTime: 100,\n        FileLastModified: ctx.fileModifiedTime,\n        Filename: ctx.fileName,\n        fileSize: ctx.fileSize,\n      },\n    ],\n    StatisticsArray: [],\n    StreamingInfoList: [],\n    iTMSTransporterMode: 'upload',\n    PackagePathWithoutBase: null,\n    NewPackageName: ctx.packageName,\n    Transport: 'HTTP',\n    TransferTime: ctx.transferTime,\n    NumberBytesTransferred: ctx.fileSize + ctx.metadataSize,\n    Username: ctx.username,\n    Version: '2.0.0',\n  });\n\n  if (!res.Success) {\n    throw constructError('Upload completion failed!', res);\n  }\n}\n\nmodule.exports = {\n  SOFTWARE_SERVICE_URL,\n  PRODUCER_SERVICE_URL,\n  constructError,\n  generateMetadata,\n  makeSoftwareServiceRequest,\n  makeProducerServiceRequest,\n  authenticateForSession,\n  lookupSoftwareForBundleId,\n  validateMetadata,\n  validateAssets,\n  clientChecksumCompleted,\n  createReservation,\n  executeOperation,\n  commitReservation,\n  uploadDoneWithArguments,\n};\n"
  },
  {
    "path": "lib/utility.js",
    "content": "const fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst crypto = require('crypto');\nconst stream = require('stream');\nconst zlib = require('zlib');\nconst axios = require('axios');\nconst yauzl = require('yauzl');\nconst plist = require('simple-plist');\nconst prettyBytes = require('pretty-bytes');\nconst concat = require('concat-stream');\nconst { promisify } = require('util');\n\nconst INFO_PLIST_FILE_PATTERN = /^Payload\\/[^/]*.app\\/Info\\.plist$/;\n\nexports.generateIDString = function () {\n  // YYYYMMDDHHmmss-sss\n  return new Date().toISOString().replace(/-|:|T|Z/g, '').replace('.', '-');\n};\n\nexports.makeSessionDigest = function (sessionId, requestChecksum, requestId, sharedSecret) {\n  return crypto.createHash('md5')\n    .update(sessionId)\n    .update(requestChecksum)\n    .update(requestId)\n    .update(sharedSecret)\n    .digest('hex');\n};\n\nexports.openFile = function (path, flags = 'r') {\n  return new Promise((resolve, reject) => {\n    fs.open(path, flags, (err, fd) => {\n      if (err) return reject(err);\n      resolve(fd);\n    });\n  });\n};\n\nexports.closeFile = function (fd) {\n  return new Promise((resolve, reject) => {\n    fs.close(fd, (err) => {\n      if (err) return reject(err);\n      resolve();\n    });\n  });\n};\n\nexports.readFileDataFromZip = function (fd, fileNamePattern) {\n  return new Promise((resolve, reject) => {\n    yauzl.fromFd(fd, { autoClose: false, lazyEntries: true }, (err, zipFile) => {\n      if (err) return reject(err);\n      zipFile.on('error', reject);\n      zipFile.on('entry', (entry) => {\n        if (fileNamePattern.test(entry.fileName)) {\n          zipFile.openReadStream(entry, (err, stream) => {\n            if (err) throw err;\n            stream.pipe(concat(resolve));\n          });\n        }\n        else {\n          zipFile.readEntry();\n        }\n      });\n      zipFile.on('end', () => {\n        resolve(null);\n      });\n      zipFile.readEntry();\n    });\n  });\n};\n\nexports.extractBundleIdAndVersion = async function (fd) {\n  let data;\n\n  try {\n    data = await exports.readFileDataFromZip(fd, INFO_PLIST_FILE_PATTERN);\n  }\n  catch {\n    // Ignore this error, handled below.\n  }\n\n  if (!data || data.length === 0) {\n    throw new Error('Info.plist not found');\n  }\n\n  let infoPlist;\n  try {\n    infoPlist = plist.parse(data, 'Info.plist');\n  }\n  catch {\n    throw new Error('Failed to parse Info.plist');\n  }\n\n  if (infoPlist && infoPlist.CFBundleIdentifier && infoPlist.CFBundleVersion && infoPlist.CFBundleShortVersionString) {\n    return {\n      bundleId: infoPlist.CFBundleIdentifier,\n      bundleVersion: infoPlist.CFBundleVersion,\n      bundleShortVersion: infoPlist.CFBundleShortVersionString,\n    };\n  }\n\n  throw new Error('Bundle info not found in Info.plist');\n};\n\nexports.ensureTempDir = async function () {\n  const tempDir = path.join(os.tmpdir(), 'ios-uploader');\n  await fs.promises.mkdir(tempDir, { recursive: true });\n  return tempDir;\n};\n\nexports.downloadTempFile = async function (fileUrl, onProgress = () => { }) {\n  const res = await axios.get(fileUrl, {\n    responseType: 'stream',\n  });\n  let newFilePath = path.join(\n    await exports.ensureTempDir(),\n    Math.random().toString(16).substr(2, 8) + '.ipa',\n  );\n  const writer = fs.createWriteStream(newFilePath);\n\n  const contentLength = Number(res.headers['content-length'] || 0);\n  let downloaded = 0;\n  if (contentLength > 0) {\n    onProgress(0, contentLength);\n    res.data.on('data', (chunk) => onProgress(downloaded += chunk.length, contentLength));\n  }\n\n  res.data.pipe(writer);\n  await promisify(stream.finished)(writer);\n  return newFilePath;\n};\n\nexports.removeTempFile = async function (filePath) {\n  await fs.promises.unlink(filePath);\n};\n\nexports.getFileStats = function (fd) {\n  return new Promise((resolve, reject) => {\n    fs.fstat(fd, (err, stats) => {\n      if (err) return reject(err);\n      resolve(stats);\n    });\n  });\n};\n\nexports.readFile = function (path, encoding = 'utf-8') {\n  return new Promise((resolve, reject) => {\n    fs.readFile(path, encoding, (err, f) => {\n      if (err) return reject(err);\n      resolve(f);\n    });\n  });\n};\n\nexports.getFileMD5 = function (fd) {\n  return new Promise((resolve, reject) => {\n    const output = crypto.createHash('md5');\n    const input = fs.createReadStream('', { fd, start: 0, autoClose: false });\n    input.on('error', (err) => reject(err));\n    output.once('readable', () => {\n      resolve(output.read().toString('hex'));\n    });\n    input.pipe(output);\n  });\n};\n\nexports.getFilePart = function (fd, offset, length) {\n  return new Promise((resolve, reject) => {\n    let buffer = Buffer.allocUnsafe(length);\n    fs.read(fd, buffer, 0, length, offset, (err) => {\n      if (err) return reject(err);\n      resolve(buffer);\n    });\n  });\n};\n\nexports.getStringMD5 = function (text) {\n  return crypto.createHash('md5').update(text).digest('hex');\n};\n\nexports.getStringMD5Buffer = function (text) {\n  return crypto.createHash('md5').update(text).digest();\n};\n\nexports.bufferToGZBase64 = function (buf) {\n  return new Promise((resolve, reject) => {\n    zlib.gzip(buf, (err, res) => {\n      if (err) return reject(err);\n      resolve(res.toString('base64'));\n    });\n  });\n};\n\nexports.formatSpeedAndEta = function (bytes, total, duration) {\n  return {\n    speed: prettyBytes(Math.round((bytes / duration) * 1000)) + '/s',\n    eta: Math.round(((total - bytes) / (bytes / duration)) / 1000) + 's',\n  };\n};\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"ios-uploader\",\n  \"version\": \"3.0.3\",\n  \"description\": \"Easy to use, cross-platform tool to upload an iOS app to itunes-connect.\",\n  \"keywords\": [\n    \"ipa\",\n    \"upload\",\n    \"ios\"\n  ],\n  \"homepage\": \"https://github.com/simonnilsson/ios-uploader#readme\",\n  \"repository\": \"https://github.com/simonnilsson/ios-uploader\",\n  \"author\": \"Simon Nilsson <simon@nilsson.ml>\",\n  \"license\": \"MIT\",\n  \"main\": \"./lib/index.js\",\n  \"bin\": {\n    \"ios-uploader\": \"./bin/cli.js\"\n  },\n  \"scripts\": {\n    \"start\": \"node bin/cli.js\",\n    \"build\": \"pkg --out-path build --compress Brotli .\",\n    \"lint\": \"eslint --max-warnings 0 ./bin/*.js ./lib/*.js\",\n    \"fix\": \"eslint --fix ./bin/*.js ./lib/*.js\",\n    \"test\": \"nyc --reporter=text mocha\",\n    \"coverage\": \"nyc --reporter=lcov mocha\"\n  },\n  \"engines\": {\n    \"node\": \">=18.0.0\"\n  },\n  \"dependencies\": {\n    \"async\": \"^3.2.6\",\n    \"axios\": \"^0.30.0\",\n    \"cli-progress\": \"^3.12.0\",\n    \"commander\": \"^13.1.0\",\n    \"concat-stream\": \"^2.0.0\",\n    \"pretty-bytes\": \"^5.6.0\",\n    \"simple-plist\": \"^1.3.1\",\n    \"yauzl\": \"^3.2.0\"\n  },\n  \"devDependencies\": {\n    \"@stylistic/eslint-plugin\": \"^4.2.0\",\n    \"@yao-pkg/pkg\": \"^6.4.0\",\n    \"eslint\": \"^9.25.0\",\n    \"eslint-plugin-jsdoc\": \"^50.6.9\",\n    \"mocha\": \"^11.1.0\",\n    \"nock\": \"^14.0.4\",\n    \"nyc\": \"^17.1.0\",\n    \"sinon\": \"^20.0.0\"\n  },\n  \"files\": [\n    \"assets/\",\n    \"bin/\",\n    \"lib/\"\n  ],\n  \"pkg\": {\n    \"scripts\": [\n      \"./bin/**/*.js\",\n      \"./lib/**/*.js\"\n    ],\n    \"assets\": \"./assets/**/*\",\n    \"targets\": [\n      \"node18-win-x64\",\n      \"node18-macos-x64\",\n      \"node18-linux-x64\",\n      \"node18-alpine-x64\"\n    ]\n  }\n}"
  },
  {
    "path": "test/index.test.js",
    "content": "const assert = require('assert').strict;\nconst sinon = require('sinon');\nconst nock = require('nock');\nconst url = require('url');\n\nconst index = require('../lib/index');\nconst utility = require('../lib/utility');\n\ndescribe('lib/index', () => {\n\n  const TEST_CTX = {\n    filePath: '/PATH/TO/FILE',\n    fileName: 'FILE',\n    fileHandle: 'FD',\n    fileSize: 12345,\n    fileModifiedTime: 1577930645678,\n    fileChecksum: 'FILE_CHECKSUM',\n    metadataChecksum: '95ceb84069b68b06b5d7820ef537d22a',\n    metadataCompressed: \"H4sIAAAAAAAACl1QW0vDMBh9F/wP4Xu3cbqCSNIhs8PhhIHbc4jp1y2sudCk3n69bbfasbfk3L7DYbNvU5FPrIN2lsMkuQWCVrlC2x2H7WZx8wCz7PqKeakOcoekldvAYR+jf6RUel9hopyhOjYWA9XGuzpiDWNmcGX8kjWmyRTaJELYgAgZAsZA+hShCw5P6/UqF8tn6DDhKxlLVxsO2oWjt3X3JhJ/PHL4aGxR4UC1ZCGjFKWu8B/q7ulfzCZ399OU0f59xnVaYaXBbLFc5YyO/zOR2qM6hMacrpoihV4u5i/5/PV9+8boIBmr0MsujPbVjxvQixG6jelp5OwPNMlY5pYBAAA=\",\n    metadataSize: 406,\n    appleId: 'APPLE_ID',\n    bundleId: 'BUNDLE_ID',\n    bundleVersion: 'BUNDLE_VERSION',\n    bundleShortVersion: 'BUNDLE_SHORT_VERSION',\n    sessionId: 'SESSION_ID',\n    sharedSecret: 'SECRET',\n    appName: 'APP_NAME',\n    appIconUrl: 'ICON_URL',\n    packageName: 'PACKAGE_NAME'\n  }\n\n  describe('constructError()', () => {\n    it('should return a formated error', () => {\n      let err = index.constructError(\"MESSAGE\", { ErrorMessage: 'RESPONSE_ERROR' });\n      assert.ok(err instanceof Error);\n      assert.equal(err.message, 'MESSAGE\\nRESPONSE_ERROR');\n    });\n  });\n\n  describe('generateMetadata()', () => {\n\n    before(() => {\n      sinon.stub(utility, 'getFileStats').withArgs(TEST_CTX.fileHandle).resolves({\n        size: TEST_CTX.fileSize,\n        mtimeMs: TEST_CTX.fileModifiedTime + 0.1\n      });\n      sinon.stub(utility, 'getFileMD5').withArgs(TEST_CTX.fileHandle).resolves(TEST_CTX.fileChecksum);\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should correctly format ID based on current time', async () => {\n      const METADATA_INPUT = {\n        fileHandle: TEST_CTX.fileHandle,\n        filePath: TEST_CTX.filePath,\n        appleId: TEST_CTX.appleId\n      };\n      const ctx = Object.assign({}, METADATA_INPUT);\n      await index.generateMetadata(ctx);\n      const EXPECTED_METADATA = {\n        fileName: TEST_CTX.fileName,\n        fileSize: TEST_CTX.fileSize,\n        fileModifiedTime: TEST_CTX.fileModifiedTime,\n        fileChecksum: TEST_CTX.fileChecksum,\n        metadataBuffer: sinon.match.instanceOf(Buffer),\n        metadataChecksum: sinon.match.string,\n        metadataCompressed: sinon.match.string,\n        metadataSize: sinon.match.number\n      };\n      sinon.assert.match(ctx, Object.assign({}, METADATA_INPUT, EXPECTED_METADATA));\n    });\n\n  });\n\n  describe('makeSoftwareServiceRequest()', () => {\n\n    before(() => {\n\n      const serviceUrl = new url.URL(index.SOFTWARE_SERVICE_URL);\n\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'test',\n          id: sinon.match.string,\n          params: {}\n        }).test(body))\n        .reply(200, { result: { Success: true } });\n\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      let res = await index.makeSoftwareServiceRequest({ sessionId: TEST_CTX.sessionId, sharedSecret: TEST_CTX.sharedSecret }, 'test', {});\n      sinon.assert.match(res, { Success: true });\n    });\n\n  });\n\n  describe('makeProducerServiceRequest()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'test',\n          id: sinon.match.string,\n          params: {}\n        }).test(body))\n        .reply(200, { result: { Success: true } });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      let res = await index.makeProducerServiceRequest({ sessionId: TEST_CTX.sessionId, sharedSecret: TEST_CTX.sharedSecret }, 'test', {});\n      sinon.assert.match(res, { Success: true });\n    });\n\n  });\n\n  describe('authenticateForSession()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'authenticateForSession',\n          id: sinon.match.string,\n          params: { Username: TEST_CTX.username, Password: TEST_CTX.password }\n        }).test(body))\n        .reply(200, { result: { SessionId: TEST_CTX.sessionId, SharedSecret: TEST_CTX.sharedSecret } });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, { result: { Success: false } });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = {\n        username: TEST_CTX.username,\n        password: TEST_CTX.password\n      };\n      await index.authenticateForSession(ctx);\n      sinon.assert.match(ctx, { sessionId: TEST_CTX.sessionId, sharedSecret: TEST_CTX.sharedSecret });\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = {\n        username: TEST_CTX.username,\n        password: 'WRONG_PASSWORD'\n      };\n      await assert.rejects(index.authenticateForSession(ctx));\n    });\n\n  });\n\n  describe('lookupSoftwareForBundleId()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.SOFTWARE_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'lookupSoftwareForBundleId',\n          id: sinon.match.string,\n          params: {\n            Application: 'altool',\n            ApplicationBundleId: 'com.apple.itunes.altool',\n            BundleId: TEST_CTX.bundleId,\n            Version: '4.0.1 (1182)'\n          }\n        }).test(body))\n        .reply(200, {\n          result: {\n            Success: true,\n            Attributes: [{ AppleID: TEST_CTX.appleId, Application: TEST_CTX.appName, IconURL: TEST_CTX.appIconUrl }]\n          }\n        });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, {\n          result: {\n            Success: false\n          }\n        });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = {\n        bundleId: TEST_CTX.bundleId\n      };\n      await index.lookupSoftwareForBundleId(ctx);\n      sinon.assert.match(ctx, { appleId: TEST_CTX.appleId, appName: TEST_CTX.appName, appIconUrl: TEST_CTX.appIconUrl });\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = {\n        bundleId: 'WRONG_BUNDLE_ID'\n      };\n      await assert.rejects(index.lookupSoftwareForBundleId(ctx));\n    });\n\n  });\n\n  describe('validateMetadata()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'validateMetadata',\n          id: sinon.match.string,\n          params: {\n            Application: 'iTMSTransporter',\n            BaseVersion: '2.0.0',\n            Files: [\n              TEST_CTX.fileName,\n              'metadata.xml'\n            ],\n            iTMSTransporterMode: 'upload',\n            MetadataChecksum: TEST_CTX.metadataChecksum,\n            MetadataCompressed: TEST_CTX.metadataCompressed,\n            MetadataInfo: {\n              app_platform: 'ios',\n              apple_id: TEST_CTX.appleId,\n              asset_types: [\n                'bundle'\n              ],\n              bundle_identifier: TEST_CTX.bundleId,\n              bundle_short_version_string: TEST_CTX.bundleShortVersion,\n              bundle_version: TEST_CTX.bundleVersion,\n              device_id: '',\n              packageVersion: 'software5.4',\n              primary_bundle_identifier: ''\n            },\n            PackageName: TEST_CTX.packageName,\n            PackageSize: TEST_CTX.fileSize + TEST_CTX.metadataSize,\n            Username: TEST_CTX.username,\n            Version: '2.0.0'\n          }\n        }).test(body))\n        .reply(200, {\n          result: {\n            Success: true\n          }\n        });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, {\n          result: {\n            Success: false\n          }\n        });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = Object.assign({}, TEST_CTX);\n      await index.validateMetadata(ctx);\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = {\n      };\n      await assert.rejects(index.validateMetadata(ctx));\n    });\n\n  });\n\n  describe('validateAssets()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'validateAssets',\n          id: sinon.match.string,\n          params: {\n            Application: 'iTMSTransporter',\n            BaseVersion: '2.0.0',\n            Files: [\n              TEST_CTX.fileName,\n              'metadata.xml'\n            ],\n            iTMSTransporterMode: 'upload',\n            MetadataChecksum: TEST_CTX.metadataChecksum,\n            MetadataCompressed: TEST_CTX.metadataCompressed,\n            MetadataInfo: {\n              app_platform: 'ios',\n              apple_id: TEST_CTX.appleId,\n              asset_types: [\n                'bundle'\n              ],\n              bundle_identifier: TEST_CTX.bundleId,\n              bundle_short_version_string: TEST_CTX.bundleShortVersion ,\n              bundle_version: TEST_CTX.bundleVersion,\n              device_id: '',\n              packageVersion: 'software5.4',\n              primary_bundle_identifier: ''\n            },\n            PackageName: TEST_CTX.packageName,\n            PackageSize: TEST_CTX.fileSize + TEST_CTX.metadataSize,\n            StreamingInfoList: [],\n            Transport: 'HTTP',\n            Username: TEST_CTX.username,\n            Version: '2.0.0'\n          }\n        }).test(body))\n        .reply(200, {\n          result: {\n            Success: true,\n            NewPackageName: 'NEW_PACKAGE_NAME'\n          }\n        });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, {\n          result: {\n            Success: false\n          }\n        });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = Object.assign({}, TEST_CTX);\n      await index.validateAssets(ctx);\n      sinon.assert.match(ctx, { packageName: 'NEW_PACKAGE_NAME' });\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = {\n      };\n      await assert.rejects(index.validateAssets(ctx));\n    });\n\n  });\n\n  describe('clientChecksumCompleted()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'clientChecksumCompleted',\n          id: sinon.match.string,\n          params: {\n            Application: 'iTMSTransporter',\n            BaseVersion: '2.0.0',\n            iTMSTransporterMode: 'upload',\n            NewPackageName: TEST_CTX.packageName,\n            Username: TEST_CTX.username,\n            Version: '2.0.0'\n          }\n        }).test(body))\n        .reply(200, {\n          result: {\n            Success: true\n          }\n        });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, {\n          result: {\n            Success: false\n          }\n        });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = Object.assign({}, TEST_CTX);\n      await index.clientChecksumCompleted(ctx);\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = {\n      };\n      await assert.rejects(index.clientChecksumCompleted(ctx));\n    });\n\n  });\n\n  describe('createReservation()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'createReservation',\n          id: sinon.match.string,\n          params: {\n            Application: 'iTMSTransporter',\n            BaseVersion: '2.0.0',\n            fileDescriptions: [\n              {\n                checksum: TEST_CTX.metadataChecksum,\n                checksumAlgorithm: 'MD5',\n                contentType: 'application/xml',\n                fileName: 'metadata.xml',\n                fileSize: TEST_CTX.metadataSize\n              },\n              {\n                checksum: TEST_CTX.fileChecksum,\n                checksumAlgorithm: 'MD5',\n                contentType: 'application/octet-stream',\n                fileName: TEST_CTX.fileName,\n                fileSize: TEST_CTX.fileSize,\n                uti: 'com.apple.ipa'\n              }\n            ],\n            iTMSTransporterMode: 'upload',\n            NewPackageName: TEST_CTX.packageName,\n            Username: TEST_CTX.username,\n            Version: '2.0.0'\n          }\n        }).test(body))\n        .reply(200, {\n          result: {\n            Success: true,\n            Reservations: []\n          }\n        });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, {\n          result: {\n            Success: false\n          }\n        });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = Object.assign({}, TEST_CTX);\n      await index.createReservation(ctx);\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = {};\n      await assert.rejects(index.createReservation(ctx));\n    });\n\n  });\n\n  describe('executeOperation()', () => {\n\n    const TEST_METADATA_OPERATION = {\n      uri: 'https://example.com/fileupload/metadata',\n      method: 'PUT',\n      offset: 0,\n      headers: {\n        'Content-Type': 'application/xml'\n      },\n      length: TEST_CTX.metadataSize\n    };\n\n    const TEST_BINARY_OPERATION = {\n      uri: 'https://example.com/fileupload/binary',\n      method: 'PUT',\n      offset: 10,\n      headers: {\n        'Content-Type': 'application/octet-stream'\n      },\n      length: 20\n    };\n\n    const TEST_BINARY_OPERATION_NETWORK_ERROR = {\n      uri: 'https://example.com/fileupload/error',\n      method: 'PUT',\n      offset: 10,\n      headers: {\n        'Content-Type': 'application/octet-stream'\n      },\n      length: 20\n    };\n\n    before(() => {\n      const metadataUrl = new url.URL(TEST_METADATA_OPERATION.uri);\n      nock(metadataUrl.origin)\n        .matchHeader('Content-Type', TEST_METADATA_OPERATION.headers['Content-Type'])\n        .intercept(metadataUrl.pathname, TEST_METADATA_OPERATION.method, (body) => body.length === TEST_METADATA_OPERATION.length)\n        .reply(200);\n\n      sinon.stub(utility, 'getFilePart')\n        .withArgs(TEST_CTX.fileHandle, TEST_BINARY_OPERATION.offset, TEST_BINARY_OPERATION.length)\n        .resolves(Buffer.alloc(TEST_BINARY_OPERATION.length));\n\n      const binaryUrl = new url.URL(TEST_BINARY_OPERATION.uri);\n      nock(binaryUrl.origin)\n        .matchHeader('Content-Type', TEST_BINARY_OPERATION.headers['Content-Type'])\n        .intercept(binaryUrl.pathname, TEST_BINARY_OPERATION.method, (body) => body.length === TEST_BINARY_OPERATION.length)\n        .reply(200);\n\n      nock(binaryUrl.origin)\n        .intercept(/.*/, TEST_BINARY_OPERATION.method)\n        .reply(400);\n\n        const errorUrl = new url.URL(TEST_BINARY_OPERATION_NETWORK_ERROR.uri);\n        nock(errorUrl.origin)\n          .matchHeader('Content-Type', TEST_BINARY_OPERATION_NETWORK_ERROR.headers['Content-Type'])\n          .intercept(errorUrl.pathname, TEST_BINARY_OPERATION_NETWORK_ERROR.method, (body) => body.length === TEST_BINARY_OPERATION_NETWORK_ERROR.length)\n          .replyWithError('Network Error');\n\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request for metadata.xml', async () => {\n      const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);\n      ctx.metadataBuffer = Buffer.alloc(ctx.metadataSize);\n      await index.executeOperation({ ctx, reservation: { file: 'metadata.xml' }, operation: TEST_METADATA_OPERATION });\n      sinon.assert.match(ctx, { bytesSent: TEST_METADATA_OPERATION.length });\n    });\n\n    it('should make the appropriate HTTP request for binary', async () => {\n      const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);\n      await index.executeOperation({ ctx, reservation: { file: TEST_CTX.fileName }, operation: TEST_BINARY_OPERATION });\n      sinon.assert.match(ctx, { bytesSent: TEST_BINARY_OPERATION.length });\n    });\n\n    it('should do nothing on unknown file', async () => {\n      const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);\n      await index.executeOperation({ ctx, reservation: { file: 'unknown' }, operation: {} });\n      sinon.assert.match(ctx, { bytesSent: 0 });\n    });\n\n    it('should reject on status error', async () => {\n      const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);\n      ctx.metadataBuffer = Buffer.alloc(ctx.metadataSize);\n      const wrongOperation = Object.assign({}, TEST_METADATA_OPERATION, { length: 0 })\n      await assert.rejects(index.executeOperation({ ctx, reservation: { file: 'metadata.xml' }, operation: wrongOperation }));\n    });\n\n    it('should reject on request error', async () => {\n      const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);\n      await assert.rejects(index.executeOperation({ ctx, reservation: { file: TEST_CTX.fileName }, operation: TEST_BINARY_OPERATION_NETWORK_ERROR }));\n    });\n\n  });\n\n  describe('commitReservation()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'commitReservation',\n          id: sinon.match.string,\n          params: {\n            Application: 'iTMSTransporter',\n            BaseVersion: '2.0.0',\n            iTMSTransporterMode: 'upload',\n            NewPackageName: TEST_CTX.packageName,\n            reservations: [\n              'RESERVATION_ID'\n            ],\n            Username: TEST_CTX.username,\n            Version: '2.0.0'\n          }\n        }).test(body))\n        .reply(200, {\n          result: {\n            Success: true\n          }\n        });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, {\n          result: {\n            Success: false\n          }\n        });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = Object.assign({}, TEST_CTX);\n      await index.commitReservation(ctx, { id: 'RESERVATION_ID' });\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = Object.assign({}, TEST_CTX);\n      await assert.rejects(index.commitReservation(ctx, { id: 'WRONG_RESERVATION_ID' }));\n    });\n\n  });\n\n  describe('uploadDoneWithArguments()', () => {\n\n    before(() => {\n      const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname, (body) => sinon.match({\n          jsonrpc: '2.0',\n          method: 'uploadDoneWithArguments',\n          id: sinon.match.string,\n          params: {\n            Application: 'iTMSTransporter',\n            BaseVersion: '2.0.0',\n            FileSizeInfo: {\n              [TEST_CTX.fileName]: TEST_CTX.fileSize,\n              \"metadata.xml\": TEST_CTX.metadataSize\n            },\n            ClientChecksumInfo: [\n              {\n                CalculatedChecksum: TEST_CTX.fileChecksum,\n                CalculationTime: 100,\n                FileLastModified: TEST_CTX.fileModifiedTime,\n                Filename: TEST_CTX.fileName,\n                fileSize: TEST_CTX.fileSize\n              }\n            ],\n            StatisticsArray: [],\n            StreamingInfoList: [],\n            iTMSTransporterMode: 'upload',\n            PackagePathWithoutBase: null,\n            NewPackageName: TEST_CTX.packageName,\n            Transport: 'HTTP',\n            TransferTime: TEST_CTX.transferTime,\n            NumberBytesTransferred: TEST_CTX.fileSize + TEST_CTX.metadataSize,\n            Username: TEST_CTX.username,\n            Version: '2.0.0'\n          }\n        }).test(body))\n        .reply(200, {\n          result: {\n            Success: true\n          }\n        });\n      nock(serviceUrl.origin)\n        .post(serviceUrl.pathname)\n        .reply(200, {\n          result: {\n            Success: false\n          }\n        });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should make the appropriate HTTP request', async () => {\n      const ctx = Object.assign({}, TEST_CTX);\n      await index.uploadDoneWithArguments(ctx);\n    });\n\n    it('should reject on failure', async () => {\n      const ctx = {};\n      await assert.rejects(index.uploadDoneWithArguments(ctx));\n    });\n\n  });\n\n});"
  },
  {
    "path": "test/utility.test.js",
    "content": "const assert = require('assert').strict;\nconst sinon = require('sinon');\nconst fs = require('fs');\nconst stream = require('stream');\nconst yauzl = require(\"yauzl\");\nconst zlib = require('zlib');\nconst axios = require('axios');\n\nconst utility = require('../lib/utility');\n\nconst TEST_PLIST = `\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>CFBundleIdentifier</key>\n    <string>BUNDLE_IDENTIFIER</string>\n    <key>CFBundleVersion</key>\n    <string>BUNDLE_VERSION</string>\n    <key>CFBundleShortVersionString</key>\n    <string>BUNDLE_SHORT_VERSION</string>\n  </dict>\n</plist>\n`.trim();\n\nconst EMPTY_PLIST = `\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n  </dict>\n</plist>\n`.trim();\n\ndescribe('lib/utility', () => {\n\n  describe('generateIDString()', () => {\n\n    let clock;\n\n    before(() => {\n      clock = sinon.useFakeTimers({\n        now: new Date(\"2020-01-02T03:04:05.678Z\"),\n        shouldAdvanceTime: false,\n      });\n    });\n\n    after(() => {\n      clock.restore();\n    });\n\n    it('should correctly format ID based on current time', () => {\n      assert.equal(utility.generateIDString(), '20200102030405-678');\n    });\n\n  });\n\n  describe('makeSessionDigest()', () => {\n\n    it('should generate a valid digest string', () => {\n      assert.equal(utility.makeSessionDigest('SESSION-ID', 'REQUEST_CHECKSUM', 'REQUEST-ID', 'SECRET'), 'af7b0121fe12199cdb5d765b73bd7cb5');\n    });\n\n  });\n\n  describe('openFile()', () => {\n\n    before(() => {\n      let stub = sinon.stub(fs, 'open')\n      stub.withArgs('VALIDPATH').yields(undefined, 1);\n      stub.withArgs('WRONGPATH').yields(new Error(), undefined);\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve with file-descriptor on success', async () => {\n      let fd = await utility.openFile('VALIDPATH');\n      assert.equal(fd, 1);\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.openFile('WRONGPATH'));\n    });\n\n  });\n\n  describe('closeFile()', () => {\n\n    before(() => {\n      let stub = sinon.stub(fs, 'close')\n      stub.withArgs(1).yields(undefined);\n      stub.withArgs(0).yields(new Error());\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      await utility.closeFile(1);\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.closeFile(0));\n    });\n\n  });\n\n  \n  describe('readFileDataFromZip()', () => {\n\n    before(() => {\n\n      let fromFdStub = sinon.stub(yauzl, 'fromFd')\n\n      let zipFileOK = {\n        on: () => {},\n        openReadStream: () => {},\n        readEntry: () => {}\n      };\n\n      let zipFileOKMock = sinon.mock(zipFileOK);\n      let okEntry = { fileName: 'Payload/Test.app/Info.plist'};\n      let okStream = new stream.Readable({\n        read: function() {\n          this.push(TEST_PLIST);\n          this.push(null);\n        }\n      });\n      zipFileOKMock.expects('readEntry').once().returns();\n      zipFileOKMock.expects('openReadStream').withArgs(okEntry).yields(null, okStream);\n      zipFileOKMock.expects('on').withArgs('entry').yields(okEntry);\n      zipFileOKMock.expects('on').withArgs('error').returns();\n      zipFileOKMock.expects('on').withArgs('end').returns();\n\n      fromFdStub.withArgs(0, sinon.match.object)\n        .yields(null, zipFileOK);\n\n      let zipFileReadErr = {\n        on: () => {},\n        openReadStream: () => {},\n        readEntry: () => {}\n      };\n\n      let zipFileReadErrMock = sinon.mock(zipFileReadErr);\n      \n      zipFileReadErrMock.expects('readEntry').once().returns();\n      zipFileReadErrMock.expects('openReadStream').withArgs(okEntry).yields(new Error('STREAM_ERR'), null);\n      zipFileReadErrMock.expects('on').withArgs('entry').yields(okEntry);\n      zipFileReadErrMock.expects('on').withArgs('error').returns();\n      zipFileReadErrMock.expects('on').withArgs('end').returns();\n\n      fromFdStub.withArgs(1, sinon.match.object)\n        .yields(null, zipFileReadErr);\n\n      let zipFileWrong = {\n        on: () => {},\n        openReadStream: () => {},\n        readEntry: () => {}\n      };\n\n      let zipFileWrongMock = sinon.mock(zipFileWrong);\n      let wrongEntry = { fileName: 'Payload/Test.app/other.file'};\n      zipFileWrongMock.expects('readEntry').once().returns();\n      zipFileWrongMock.expects('openReadStream').never();\n      zipFileWrongMock.expects('on').withArgs('entry').yields(wrongEntry);\n      zipFileWrongMock.expects('on').withArgs('error').returns();\n      zipFileWrongMock.expects('on').withArgs('end').yields();\n\n      fromFdStub.withArgs(2, sinon.match.object)\n        .yields(null, zipFileWrong);\n\n      fromFdStub.withArgs(3, sinon.match.object)\n        .yields(new Error('TEST_ERROR'), null);\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      let data = await utility.readFileDataFromZip(0, /^Payload\\/[^/]*.app\\/Info\\.plist$/);\n      sinon.assert.match(data, sinon.match.instanceOf(Buffer));\n    });\n\n    it('should throw if unable to open read stream', async () => {\n      await assert.rejects(utility.readFileDataFromZip(1, /^Payload\\/[^/]*.app\\/Info\\.plist$/), { message: 'STREAM_ERR' });\n    });\n\n    it('should resolve to null if not found', async () => {\n      let data = await utility.readFileDataFromZip(2, /^Payload\\/[^/]*.app\\/Info\\.plist$/);\n      sinon.assert.match(data, null);\n    });\n\n    it('should throw if unable to read file', async () => {\n      await assert.rejects(utility.readFileDataFromZip(3, /^Payload\\/[^/]*.app\\/Info\\.plist$/), { message: 'TEST_ERROR' });\n    });\n  });\n\n  describe('extractBundleIdAndVersion()', () => {\n\n    before(() => {\n\n      let readFileDataFromZipStub = sinon.stub(utility, 'readFileDataFromZip');\n\n      readFileDataFromZipStub\n        .withArgs(0, sinon.match.regexp)\n        .resolves(Buffer.from(TEST_PLIST));\n\n      readFileDataFromZipStub\n        .withArgs(1, sinon.match.regexp)\n        .resolves(null);\n\n      readFileDataFromZipStub\n        .withArgs(2, sinon.match.regexp)\n        .resolves(Buffer.from('INVALID'));\n\n      readFileDataFromZipStub\n        .withArgs(3, sinon.match.regexp)\n        .resolves(Buffer.from(EMPTY_PLIST));\n\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      let bundleInfo = await utility.extractBundleIdAndVersion(0);\n      assert.deepEqual(bundleInfo, { bundleId: 'BUNDLE_IDENTIFIER', bundleVersion: 'BUNDLE_VERSION', bundleShortVersion: 'BUNDLE_SHORT_VERSION' });\n    });\n\n    it('should reject with error on failure 1', async () => {\n      await assert.rejects(utility.extractBundleIdAndVersion(1), { message: 'Info.plist not found' });\n    });\n\n    it('should reject with error on failure 2', async () => {\n      await assert.rejects(utility.extractBundleIdAndVersion(2), { message: 'Failed to parse Info.plist' });\n    });\n\n    it('should reject with error on failure 3', async () => {\n      await assert.rejects(utility.extractBundleIdAndVersion(3), { message: 'Bundle info not found in Info.plist' });\n    });\n\n  });\n\n  describe('ensureTempDir()', () => {\n\n    before(() => {\n      let stub = sinon.stub(fs.promises, 'mkdir');\n      stub.withArgs(sinon.match.string, { recursive: true }).resolves(undefined);\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      let res = await utility.ensureTempDir();\n      sinon.assert.match(res, sinon.match.string);\n    });\n\n  });\n\n  describe('downloadTempFile()', () => {\n\n    beforeEach(() => {\n      let readableStream = new stream.PassThrough();\n      readableStream.end();\n\n      let axiosStub = sinon.stub(axios, 'get');\n      axiosStub.withArgs('http://example.com/app.ipa', { responseType: 'stream' })\n        .resolves({ data: readableStream, headers: { 'content-length': 1 } });\n\n      axiosStub.withArgs('http://example.com/app-no-cl.ipa', { responseType: 'stream' })\n        .resolves({ data: readableStream, headers: { } });\n\n      let ensureTempDirStub = sinon.stub(utility, 'ensureTempDir')\n      ensureTempDirStub.resolves('PATH');\n\n      let writeStream = new stream.Writable();      \n\n      let createWriteStreamStub = sinon.stub(fs, 'createWriteStream');\n      createWriteStreamStub.withArgs(sinon.match.string).returns(writeStream);\n    });\n\n    afterEach(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      const onProgressCallback = sinon.spy();\n      let res = await utility.downloadTempFile('http://example.com/app.ipa', onProgressCallback);\n      sinon.assert.match(res, 'PATH');\n      sinon.assert.called(onProgressCallback);\n    });\n\n    it('should not call onProgress if content-length unknown', async () => {\n      const onProgressCallback = sinon.spy();\n      let res = await utility.downloadTempFile('http://example.com/app-no-cl.ipa', onProgressCallback);\n      sinon.assert.match(res, 'PATH');\n      sinon.assert.notCalled(onProgressCallback);\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.downloadTempFile());\n    });\n\n  });\n\n  describe('removeTempFile()', () => {\n\n    before(() => {\n      let unlinkStub = sinon.stub(fs.promises, 'unlink');\n      unlinkStub.withArgs('FILE_PATH').resolves();\n      unlinkStub.rejects();\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      await utility.removeTempFile('FILE_PATH');\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.removeTempFile());\n    });\n\n  });\n\n  describe('getFileStats()', () => {\n\n    before(() => {\n      let stub = sinon.stub(fs, 'fstat')\n      stub.withArgs(1).yields(undefined, {});\n      stub.withArgs(0).yields(new Error(), undefined);\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      let stats = await utility.getFileStats(1);\n      assert.deepEqual(stats, {});\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.getFileStats(0));\n    });\n\n  });\n\n  describe('readFile()', () => {\n\n    before(() => {\n      let stub = sinon.stub(fs, 'readFile')\n      stub.withArgs('VALIDPATH').yields(undefined, 'data');\n      stub.withArgs('WRONGPATH').yields(new Error(), undefined);\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      let data = await utility.readFile('VALIDPATH');\n      assert.deepEqual(data, 'data');\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.readFile('WRONGPATH'));\n    });\n\n  });\n\n  describe('getFileMD5()', () => {\n\n    before(() => {\n      let stub = sinon.stub(fs, 'createReadStream');\n      stub.withArgs(sinon.match.string, sinon.match({ fd: 1 })).callsFake(() => {\n        return new stream.Readable({\n          read: function() {\n            this.push('data');\n            this.push(null);\n          }\n        });\n      });\n      stub.withArgs(sinon.match.string, sinon.match({ fd: 0 })).callsFake(() => {\n        return new stream.Readable({\n          read: function() {\n            this.emit('error', new Error());\n            this.push(null);\n          }\n        });\n      });\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      let md5 = await utility.getFileMD5(1);\n      assert.deepEqual(md5, '8d777f385d3dfec8815d20f7496026dc');\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.getFileMD5(0));\n    });\n\n  });\n\n  describe('getFilePart()', () => {\n\n    before(() => {\n      let fsMock = sinon.mock(fs)\n      fsMock.expects('read').withArgs(1, sinon.match.instanceOf(Buffer), 0, 4, 0).callsFake((fd, buffer, offset, length, position, cb) => {\n        buffer.write('PART');\n        cb();\n      });\n      fsMock.expects('read').yields(new Error());\n    });\n\n    after(() => {\n      sinon.restore();\n    });\n\n    it('should resolve on success', async () => {\n      let part = await utility.getFilePart(1, 0, 4);\n      assert.deepEqual(part, Buffer.from('PART'));\n    });\n\n    it('should reject with error on failure', async () => {\n      await assert.rejects(utility.getFilePart(0, 0, 4));\n    });\n\n  });\n\n  describe('getStringMD5()', () => {\n\n    it('should return correct md5 hash string', () => {\n      assert.deepEqual(utility.getStringMD5('data'), '8d777f385d3dfec8815d20f7496026dc');\n    });\n\n  });\n\n  describe('getStringMD5Buffer()', () => {\n\n    it('should return correct md5 hash buffer', () => {\n      assert.deepEqual(utility.getStringMD5Buffer('data'), Buffer.from('8d777f385d3dfec8815d20f7496026dc', 'hex'));\n    });\n\n  });\n\n  describe('bufferToGZBase64()', () => {\n\n    it('should return correct md5 hash buffer', async () => {\n      let gzBase64 = await utility.bufferToGZBase64(Buffer.from('data'));\n      assert(typeof gzBase64 === 'string');\n    });\n\n    it('should reject with error on failure', async () => {\n      const zlibMock = sinon.mock(zlib);\n  \n      zlibMock.expects('gzip')\n        .withArgs(sinon.match.instanceOf(Buffer))\n        .once()\n        .yields(new Error('TEST_ERROR'), null);\n\n      await assert.rejects(utility.bufferToGZBase64(Buffer.alloc(0)));\n\n      zlibMock.verify();\n      sinon.restore();\n    });\n\n  });\n\n  describe('formatSpeedAndEta()', () => {\n\n    it('should correctly format B/s', () => {\n      assert.deepEqual(utility.formatSpeedAndEta(10, 10, 1000), { eta: '0s', speed: '10 B/s' });\n    });\n\n    it('should correctly format kB/s', () => {\n      assert.deepEqual(utility.formatSpeedAndEta(10000, 10000, 1000), { eta: '0s', speed: '10 kB/s' });\n    });\n\n    it('should correctly format MB/s', () => {\n      assert.deepEqual(utility.formatSpeedAndEta(10000000, 10000000, 1000), { eta: '0s', speed: '10 MB/s' });\n    });\n\n    it('should correctly format eta', () => {\n      assert.deepEqual(utility.formatSpeedAndEta(10, 1000, 1000), { eta: '99s', speed: '10 B/s' });\n    });\n\n  });\n\n});\n"
  }
]