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