diff --git a/android/app/build.gradle b/android/app/build.gradle index 3b152015..47c673e4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -97,6 +97,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20231013' + testImplementation 'org.mockito:mockito-core:5.14.2' androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index a4fd6998..d85d119e 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -185,6 +185,13 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN } connection.setRequestProperty("Accept-Encoding", "identity"); + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + throw new CodePushUnknownException("Error downloading update package. Response code: " + + responseCode + ". Response body: " + NetworkUtils.readStreamToString(connection.getErrorStream())); + } + bin = new BufferedInputStream(connection.getInputStream()); long totalBytes = connection.getContentLength(); @@ -234,6 +241,15 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN } } + installDownloadedUpdate(updatePackage, expectedBundleFileName, stringPublicKey, + downloadFile, isZip, newUpdateFolderPath, newUpdateMetadataPath); + } + + void installDownloadedUpdate(JSONObject updatePackage, String expectedBundleFileName, + String stringPublicKey, File downloadFile, boolean isZip, + String newUpdateFolderPath, String newUpdateMetadataPath) throws IOException { + String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null); + if (isZip) { // Unzip the downloaded file and then delete the zip String unzippedFolderPath = getUnzippedFolderPath(); @@ -301,30 +317,26 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN String signaturePath = CodePushUpdateUtils.getSignatureFilePath(newUpdateFolderPath); boolean isSignatureAppearedInBundle = FileUtils.fileAtPathExists(signaturePath); + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + throw new CodePushInvalidUpdateException( + "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + + "Possible reasons, why that might happen: \n" + + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + + "2. You've been released CodePush bundle update without providing --privateKeyPath option." + ); + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CodePushUtils.log( + "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + + "Please ensure that public key is properly configured within your application." + ); + } + + CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); - } else { - throw new CodePushInvalidUpdateException( - "Error! Public key was provided but there is no JWT signature within app bundle to verify. " + - "Possible reasons, why that might happen: \n" + - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" + - "2. You've been released CodePush bundle update without providing --privateKeyPath option." - ); - } - } else { - if (isSignatureAppearedInBundle) { - CodePushUtils.log( - "Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " + - "Please ensure that public key is properly configured within your application." - ); - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } else { - if (isDiffUpdate) { - CodePushUpdateUtils.verifyFolderHash(newUpdateFolderPath, newUpdateHash); - } - } + CodePushUpdateUtils.verifyUpdateSignature(newUpdateFolderPath, newUpdateHash, stringPublicKey); } CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath); @@ -384,6 +396,13 @@ public void downloadAndReplaceCurrentBundle(String remoteBundleUrl, String bundl try { downloadUrl = new URL(remoteBundleUrl); connection = (HttpURLConnection) (downloadUrl.openConnection()); + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + throw new CodePushUnknownException("Error downloading update package. Response code: " + + responseCode + ". Response body: " + NetworkUtils.readStreamToString(connection.getErrorStream())); + } + bin = new BufferedInputStream(connection.getInputStream()); File downloadFile = new File(getCurrentPackageBundlePath(bundleFileName)); downloadFile.delete(); diff --git a/android/app/src/main/java/com/microsoft/codepush/react/NetworkUtils.kt b/android/app/src/main/java/com/microsoft/codepush/react/NetworkUtils.kt new file mode 100644 index 00000000..2c360b39 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/NetworkUtils.kt @@ -0,0 +1,9 @@ +@file:JvmName("NetworkUtils") + +package com.microsoft.codepush.react + +import java.io.InputStream + +fun readStreamToString(inputStream: InputStream?): String { + return inputStream?.bufferedReader()?.use { it.readText() } ?: "" +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt new file mode 100644 index 00000000..8b1bf641 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerTest.kt @@ -0,0 +1,306 @@ +package com.microsoft.codepush.react + +import android.util.Log +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.MockedStatic +import org.mockito.Mockito +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class CodePushUpdateManagerTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var logMock: MockedStatic + + @Before + fun mockAndroidLog() { + // CodePushUtils.log() is used deep inside the SDK classes, which isn't stubbed for plain JVM unit tests. + // We'd rather hack around this (as long there is nothing else to mock) than moving these tests to instrumented Android tests. + logMock = Mockito.mockStatic(Log::class.java) + } + + @After + fun unmockAndroidLog() { + logMock.close() + } + + private fun manager(enableDeltaUpdates: Boolean = false) = + CodePushUpdateManager(tempFolder.newFolder("documents").absolutePath, enableDeltaUpdates) + + private fun updatePackage(hash: String) = JSONObject().apply { + put(CodePushConstants.PACKAGE_HASH_KEY, hash) + } + + private fun zipOf(vararg entries: Pair): File { + val zipFile = tempFolder.newFile("download.zip") + ZipOutputStream(zipFile.outputStream()).use { zip -> + for ((path, content) in entries) { + zip.putNextEntry(ZipEntry(path)) + zip.write(content.toByteArray()) + zip.closeEntry() + } + } + return zipFile + } + + private fun rawBundleFile(content: String): File { + val file = tempFolder.newFile("download.bundle") + file.writeText(content) + return file + } + + // Registers `hash` as the currently installed package, with the given file contents, so that + // getCurrentPackageFolderPath() resolves to it. Needed to set up diff-update scenarios. + private fun installCurrentPackage(update: CodePushUpdateManager, hash: String, files: Map): String { + val folderPath = update.getPackageFolderPath(hash) + File(folderPath).mkdirs() + for ((relativePath, content) in files) { + val file = File(folderPath, relativePath) + file.parentFile?.mkdirs() + file.writeText(content) + } + update.updateCurrentPackageInfo(JSONObject().apply { put(CodePushConstants.CURRENT_PACKAGE_KEY, hash) }) + return folderPath + } + + @Test + fun installDownloadedUpdate_rawBundle_movesFileIntoPlaceAndWritesMetadataWithoutBundlePath() { + // Given + val update = manager() + val pkg = updatePackage("hash1") + val downloadFile = rawBundleFile("raw jsbundle contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash1") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, false, newUpdateFolderPath, newUpdateMetadataPath) + + // Then + val installedBundle = File(newUpdateFolderPath, "index.android.bundle") + assertTrue(installedBundle.exists()) + assertEquals("raw jsbundle contents", installedBundle.readText()) + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) + assertEquals("hash1", metadata.getString(CodePushConstants.PACKAGE_HASH_KEY)) + assertFalse("raw bundle updates never set a bundlePath", metadata.has(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY)) + } + + @Test + fun installDownloadedUpdate_zipFullUpdate_findsBundleInNestedFolderAndRecordsItsRelativePath() { + // Given + val update = manager() + val entries = arrayOf( + "sub/index.android.bundle" to "new bundle contents", + "sub/asset.png" to "fake asset bytes", + ) + val downloadFile = zipOf(*entries) + val pkg = updatePackage("ff53f424bd583841638ff4e65f32dd71944ba72022d27ad6b8d8db8401b5bbf2") + val newUpdateFolderPath = update.getPackageFolderPath("hash2") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + + // Then + assertEquals("new bundle contents", File(newUpdateFolderPath, "sub/index.android.bundle").readText()) + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) + assertEquals( + CodePushUtils.appendPathComponent("sub", "index.android.bundle"), + metadata.getString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY), + ) + } + + @Test + fun installDownloadedUpdate_zipMissingExpectedBundle_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("other.txt" to "not a bundle") + val pkg = updatePackage("hash3") + val newUpdateFolderPath = update.getPackageFolderPath("hash3") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("A JS bundle file named \"index.android.bundle\" could not be found")) + } + } + + @Test + fun installDownloadedUpdate_zipFullUpdateWithNoPublicKeyAndNoSignatureAndWrongHash_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash4") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_publicKeyConfiguredButNoSignatureInBundle_throwsInvalidUpdateException() { + // Given + val update = manager() + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") + val pkg = updatePackage("hash5") + val newUpdateFolderPath = update.getPackageFolderPath("hash5") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("Error! Public key was provided but there is no JWT signature within app bundle to verify.")) + } + } + + @Test + fun installDownloadedUpdate_publicKeyConfiguredAndSignaturePresentButHashMismatch_throwsBeforeSignatureCheck() { + // Given + val update = manager() + val downloadFile = zipOf( + "index.android.bundle" to "new bundle contents", + "CodePush/.codepushrelease" to "not-a-real-jwt", + ) + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash6") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_noPublicKeyButSignaturePresentInBundle_stillVerifiesFolderHash() { + // Given + val update = manager() + val downloadFile = zipOf( + "index.android.bundle" to "new bundle contents", + "CodePush/.codepushrelease" to "not-a-real-jwt", + ) + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("hash7") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + } + + @Test + fun installDownloadedUpdate_diffManifestVersionOutOfRange_throwsIOException() { + // Given + val update = manager() + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":3,"deletedFiles":[],"patchedFiles":{}}""") + val pkg = updatePackage("hash8") + val newUpdateFolderPath = update.getPackageFolderPath("hash8") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected IOException") + } catch (e: java.io.IOException) { + assertTrue(e.message!!.contains("Diff manifest version 3 is not supported by this SDK version")) + } + } + + @Test + fun installDownloadedUpdate_binaryDiffUpdateWhenDisabledOnClient_throwsIOException() { + // Given + val update = manager(enableDeltaUpdates = false) + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""") + val pkg = updatePackage("hash9") + val newUpdateFolderPath = update.getPackageFolderPath("hash9") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected IOException") + } catch (e: java.io.IOException) { + assertTrue(e.message!!.contains("Received a binary diff update, but delta updates are not enabled on this client.")) + } + } + + @Test + fun installDownloadedUpdate_binaryDiffUpdateWithNoCurrentPackageInstalled_throwsInvalidUpdateException() { + // Given + val update = manager(enableDeltaUpdates = true) + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""") + val pkg = updatePackage("hash10") + val newUpdateFolderPath = update.getPackageFolderPath("hash10") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("Received a binary diff update, but no currently installed package exists to diff against (this is likely the first CodePush update for this app install). Diffing against the embedded app binary is not yet supported.")) + } + } + + @Test + fun installDownloadedUpdate_versionOneDiffUpdate_carriesOverKeptFilesDeletesRemovedOnesAndAppliesNewOnes() { + // Given + val update = manager() + installCurrentPackage(update, "current-hash", mapOf( + "kept.txt" to "kept contents", + "old_extra.txt" to "stale contents", + )) + val downloadFile = zipOf( + CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":1,"deletedFiles":["old_extra.txt"],"patchedFiles":{}}""", + "index.android.bundle" to "new bundle contents", + ) + // Deliberately wrong, so the folder-hash check at the end of the diff-update path throws - + // but only after the merge below has already run, so we can still assert on its result. + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") + val newUpdateFolderPath = update.getPackageFolderPath("new-hash") + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) + + // When / Then + try { + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) + fail("expected CodePushInvalidUpdateException from the folder hash check") + } catch (e: CodePushInvalidUpdateException) { + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) + } + + // Then (the merge above already ran, so its filesystem side effects are still checkable) + assertEquals("kept contents", File(newUpdateFolderPath, "kept.txt").readText()) + assertFalse("deletedFiles entry should have been removed", File(newUpdateFolderPath, "old_extra.txt").exists()) + assertEquals("new bundle contents", File(newUpdateFolderPath, "index.android.bundle").readText()) + assertFalse("the manifest itself should not be carried into the installed package", File(newUpdateFolderPath, CodePushConstants.DIFF_MANIFEST_FILE_NAME).exists()) + } +} diff --git a/code-push-plugin-testing-framework/script/serverUtil.js b/code-push-plugin-testing-framework/script/serverUtil.js index 30ff5ef3..bc3fafec 100644 --- a/code-push-plugin-testing-framework/script/serverUtil.js +++ b/code-push-plugin-testing-framework/script/serverUtil.js @@ -22,6 +22,7 @@ function setupServer(targetPlatform) { }); app.get("/v0.1/public/codepush/update_check", function (req, res) { exports.updateCheckCallback && exports.updateCheckCallback(req); + applyKnownPackageHash(); res.send(exports.updateResponse); console.log("Update check called from the app."); console.log("Request: " + JSON.stringify(req.query)); @@ -53,6 +54,34 @@ function setupServer(targetPlatform) { exports.server = app.listen(+targetPlatform.getServerUrl().match(serverPortRegEx)[1]); } exports.setupServer = setupServer; +/** + * The real content hash of the update archive most recently built during this test scenario. + */ +var knownPackageHash; +var _updatePackagePath; +Object.defineProperty(exports, "updatePackagePath", { + enumerable: true, + configurable: true, + get: function () { return _updatePackagePath; }, + set: function (value) { + _updatePackagePath = value; + applyKnownPackageHash(); + } +}); +/** + * Records the real content hash for the update archive that will be served next, so that + * any update_check response gets the matching package_hash instead of the one filled in + * by default. + */ +function setKnownPackageHash(packageHash) { + knownPackageHash = packageHash; +} +exports.setKnownPackageHash = setKnownPackageHash; +function applyKnownPackageHash() { + if (knownPackageHash && exports.updateResponse && exports.updateResponse.update_info) { + exports.updateResponse.update_info.package_hash = knownPackageHash; + } +} /** * Closes the server. */ diff --git a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts index 0692e505..7502411f 100644 --- a/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts +++ b/code-push-plugin-testing-framework/typings/code-push-plugin-testing-framework.d.ts @@ -290,6 +290,12 @@ declare module 'code-push-plugin-testing-framework/script/serverUtil' { * Closes the server. */ export function cleanupServer(): void; + /** + * Records the real content hash for the update archive that will be served next, so the + * next update_check response gets a matching package_hash. Pass a falsy packageHash to + * clear it. + */ + export function setKnownPackageHash(packageHash: string): void; /** * Class used to mock the codePush.checkForUpdate() response from the server. */ diff --git a/expo.js b/expo.js index d453377f..49b3cf25 100644 --- a/expo.js +++ b/expo.js @@ -89,6 +89,9 @@ const withCodePushInfoPlist = (config, options = {}) => { if (options.ios && options.ios.CodePushServerURL) { config.modResults.CodePushServerURL = options.ios.CodePushServerURL; } + if (options.ios && options.ios.CodePushPublicKey) { + config.modResults.CodePushPublicKey = options.ios.CodePushPublicKey; + } return config; }); }; @@ -324,6 +327,9 @@ const withAndroidStrings = (config, options) => { if (options.android?.CodePushServerURL) { setString('CodePushServerUrl', options.android.CodePushServerURL); } + if (options.android?.CodePushPublicKey) { + setString('CodePushPublicKey', options.android.CodePushPublicKey); + } return config; }); }; diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index 923624e8..07564e80 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -343,69 +343,51 @@ + (void)downloadPackage:(NSDictionary *)updatePackage NSString *signatureFilePath = [CodePushUpdateUtils getSignatureFilePath:newUpdateFolderPath]; BOOL isSignatureAppearedInBundle = [[NSFileManager defaultManager] fileExistsAtPath:signatureFilePath]; + if (isSignatureVerificationEnabled && !isSignatureAppearedInBundle) { + error = [CodePushErrorUtils errorWithMessage: + @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ + "Possible reasons, why that might happen: \n" \ + "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ + "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; + failCallback(error); + return; + } + + if (!isSignatureVerificationEnabled && isSignatureAppearedInBundle) { + CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ + " because there is no public key configured. " \ + "Please ensure that public key is properly configured within your application."); + } + + BOOL isHashValid = [CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath + expectedHash:newUpdateHash + error:&error]; + if (!isHashValid) { + CPLog(@"The update contents failed the data integrity check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; + } + + failCallback(error); + return; + } else { + CPLog(@"The update contents succeeded the data integrity check."); + } + if (isSignatureVerificationEnabled) { - if (isSignatureAppearedInBundle) { - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); + BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath + expectedHash:newUpdateHash + withPublicKey:publicKey + error:&error]; + if (!isSignatureValid) { + CPLog(@"The update contents failed code signing check."); + if (!error) { + error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; } - BOOL isSignatureValid = [CodePushUpdateUtils verifyUpdateSignatureFor:newUpdateFolderPath - expectedHash:newUpdateHash - withPublicKey:publicKey - error:&error]; - if (!isSignatureValid) { - CPLog(@"The update contents failed code signing check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed code signing check."]; - } - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the code signing check."); - } - } else { - error = [CodePushErrorUtils errorWithMessage: - @"Error! Public key was provided but there is no JWT signature within app bundle to verify " \ - "Possible reasons, why that might happen: \n" \ - "1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" \ - "2. You've been released CodePush bundle update without providing --privateKeyPath option."]; failCallback(error); return; - } - - } else { - BOOL needToVerifyHash; - if (isSignatureAppearedInBundle) { - CPLog(@"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed" \ - " because there is no public key configured. " \ - "Please ensure that public key is properly configured within your application."); - needToVerifyHash = true; } else { - needToVerifyHash = isDiffUpdate; - } - if(needToVerifyHash){ - if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath - expectedHash:newUpdateHash - error:&error]) { - CPLog(@"The update contents failed the data integrity check."); - if (!error) { - error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."]; - } - - failCallback(error); - return; - } else { - CPLog(@"The update contents succeeded the data integrity check."); - } + CPLog(@"The update contents succeeded the code signing check."); } } } else { diff --git a/test/codesign.ts b/test/codesign.ts new file mode 100644 index 00000000..8f222e17 --- /dev/null +++ b/test/codesign.ts @@ -0,0 +1,96 @@ +"use strict"; + +import crypto = require("crypto"); +import fs = require("fs"); +import mkdirp = require("mkdirp"); +import path = require("path"); + +import { Platform, ProjectManager, ServerUtil, setupUpdateScenario, TestConfig, TestUtil } from "code-push-plugin-testing-framework"; + +const CODEPUSH_METADATA_FILE_NAME = ".codepushrelease"; + +function isHashIgnored(relativePath: string): boolean { + return relativePath.startsWith("__MACOSX/") + || relativePath === ".DS_Store" + || relativePath.endsWith("/.DS_Store") + || relativePath === CODEPUSH_METADATA_FILE_NAME + || relativePath.endsWith(`/${CODEPUSH_METADATA_FILE_NAME}`); +} + +/** + * Computes the same content hash that the native SDKs compute over an installed update folder, so the mock server + * can hand back a package_hash that will actually match what the client expects. + */ +export function computeUpdateContentsHash(folderPath: string): string { + const manifest: string[] = []; + + const walk = (currentPath: string, relativePrefix: string) => { + for (const entryName of fs.readdirSync(currentPath)) { + const entryPath = path.join(currentPath, entryName); + const relativePath = relativePrefix ? `${relativePrefix}/${entryName}` : entryName; + + if (isHashIgnored(relativePath)) { + continue; + } + + if (fs.statSync(entryPath).isDirectory()) { + walk(entryPath, relativePath); + } else { + const fileHash = crypto.createHash("sha256").update(fs.readFileSync(entryPath)).digest("hex"); + manifest.push(`${relativePath}:${fileHash}`); + } + } + }; + + walk(folderPath, ""); + manifest.sort(); + + return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); +} + +const codeSigningPrivateKey = fs.readFileSync(path.join(__dirname, "../test/fixtures/codesigning/test-private-key.pem"), "utf8"); +export const codeSigningPublicKey = fs.readFileSync(path.join(__dirname, "../test/fixtures/codesigning/test-public-key.pem"), "utf8").trim(); + +function base64UrlEncode(input: Buffer): string { + return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Builds the RS256-signed ".codepushrelease" JWT that the native SDKs look for inside an update + * archive's "CodePush/" folder. + */ +function signUpdateContentsHash(contentHash: string): string { + const header = base64UrlEncode(Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" }))); + const payload = base64UrlEncode(Buffer.from(JSON.stringify({ contentHash }))); + const signature = base64UrlEncode(crypto.sign("RSA-SHA256", Buffer.from(`${header}.${payload}`), codeSigningPrivateKey)); + return `${header}.${payload}.${signature}`; +} + +/** + * Code-signs the update contents with the test key pair and records the real hash, so the mock + * server hands back a package_hash that matches what the client's data-integrity check computes. + */ +export function signAndRecordUpdateArchive(bundleFolder: string, isDiff: boolean): void { + // TODO(RA-4875): Diff updates clear it instead, since they are poorly implemented in the entire test harness. + // It's going to be a bigger refactor, so for now we just skip signing/hashing to avoid using a stale value in diff tests. + if (isDiff) { + ServerUtil.setKnownPackageHash(undefined); + return; + } + + const contentHash = computeUpdateContentsHash(bundleFolder); + const signatureFolder = path.join(bundleFolder, "CodePush"); + mkdirp.sync(signatureFolder); + fs.writeFileSync(path.join(signatureFolder, CODEPUSH_METADATA_FILE_NAME), signUpdateContentsHash(contentHash)); + ServerUtil.setKnownPackageHash(contentHash); +} + +export async function setupTamperedSignatureUpdateScenario(projectManager: ProjectManager, targetPlatform: Platform.IPlatform, scenarioJsPath: string, version: string): Promise { + const updatePath = await setupUpdateScenario(projectManager, targetPlatform, scenarioJsPath, version); + + const bundleFolder = path.join(TestConfig.updatesDirectory, TestConfig.TestAppName, "CodePush/"); + const tamperedHash = "0".repeat(64); + fs.writeFileSync(path.join(bundleFolder, "CodePush", CODEPUSH_METADATA_FILE_NAME), signUpdateContentsHash(tamperedHash)); + + return await TestUtil.archiveFolder(bundleFolder, "", updatePath, false); +} diff --git a/test/fixtures/codesigning/test-private-key.pem b/test/fixtures/codesigning/test-private-key.pem new file mode 100644 index 00000000..57c07622 --- /dev/null +++ b/test/fixtures/codesigning/test-private-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEApZtuvtGcQmmeUh81n/jAjeDkktkX1QryINqRZrcoofjw+w89 +FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGivmW1O0HBuCsAeiVHyiTq/t+sEiKP +0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3KEZy+Zhx54gaM5iz7ErqavpDADaW +DWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHTi6Whq/YUPWs2+7KXyLnvKyO+33YF +WnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0OcEagfdlM8YwfyOUSWn7LB0VCJaz +ltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj7wIDAQABAoIBAAIEOH7+UmbEnnbl +hmOiRcX0fRQErLOdZIFd5/NWO5ptS5HjB51ics8nkV22yCkaVbwgHBQFyBQQoVAb +rOPeJrsmxeQo0tEJRQI3vf4KIQplctTtss6bvJNrwVkzmDWU5eWuTzzM4TGJpo0T +nlta8L9+zBuZ7ZkiIR+LtnUkHGKdEHKFD4melmeZrvMCDNTGrpa2Y2bP9I11a6xZ +V1XJIxvAQRftorz03vsQXFiIcscEaho65DiAObSqpn1tfFnTbPOl+z4XjKncAVF8 +5vmkVHvERnvUuuBmSTEgGrEcCgwmYKxtOGqWw3MpeP9DWroU5T1N/Mx5xTr3GID5 +6T3+bMECgYEA6Kyg43UCouS7LD/lB5z0dflK8Wu0hzdYUR58r4e2S/WhzkiyX1rZ +aJNkrxyC8/39lGrYdfJHc+RNbViC1TMfH7Zao1lNFVP7vF70v2HzishCL95wSXNz +KZnSY3Yac1ONVP8ZbxX20QBXh9aoTsQxtOs15+XBvOXA5kJwtaaPgTECgYEAtjWW +UiLznuODzIyOQugysXN9bU0UTqUxr3QDt0yqCjA0URVlf3Ehi7OVUPerVSwgCD5m +4RXltW8pqMCLY/qPLkVFH+uZJ/Oo495TEMbyk2/4GXEX4klBMW40YI5srFtP95k+ +HrW5+gFOoVLcpwxRHcW6JateA7RlZDReruAt7x8CgYEAgcQdqx4MTVsyRNiR3LAd +61oRARpnwe4NFJjjQ2Z2NmEVUB5dVS8vB9MEmWFWa8whTFBWz1lDnpAa2rw9o7hy +SFaEsIvSoO2I/aMb700q7iEIQPhXOa/o76+5lf09fUqBDYGE5t6iHCiLqNgAYIWt +j1CLbP1IExk0f3dYswblDFECgYEAkib5pHiUoWYtWe2ETvahcuUIPpwNJegrqmiM +coL0AagYztEy0L6WAdDSfFes/myeZP5o1zMRRi8cY1fOdyuLnbnCcJAyEXHIjr7O +Mi7idJDjmMS2O7Q2rsePC8QyNy4nPpuU0F1EB9z0jUJB61xd1Fu9rGmAx8fzbCT1 +rZ/0OFECgYEAwQHaF2oxQMxd/WzuTHtKYuPAJDvONQj+hcEAEVFUGEVnauRoUuta +rhgnWrD2rte/6JqGPnfJouW+w1Y+2Mnfp4io8/cyysiEX7VWTeZL+nUERczTserl +cyujq12LNdD+YPQwJTxHguP0rbMsQ2lHlxwys1+74GGq7GqR3dIJlqc= +-----END RSA PRIVATE KEY----- diff --git a/test/fixtures/codesigning/test-public-key.pem b/test/fixtures/codesigning/test-public-key.pem new file mode 100644 index 00000000..9137b5e4 --- /dev/null +++ b/test/fixtures/codesigning/test-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZtuvtGcQmmeUh81n/jA +jeDkktkX1QryINqRZrcoofjw+w89FdW3XxQOhsjIJLr5u0xqjHde6Hzpx9sbcUGi +vmW1O0HBuCsAeiVHyiTq/t+sEiKP0VZa1mbnSM3EEQl09Si3NpLs4sHK+uvttLT3 +KEZy+Zhx54gaM5iz7ErqavpDADaWDWplIXUK/D/pQulbM1oM+04V1XkKw5bBqEHT +i6Whq/YUPWs2+7KXyLnvKyO+33YFWnHhHGbGkGLYE02sAGonYgaP220vJ4lnwLx0 +OcEagfdlM8YwfyOUSWn7LB0VCJazltDiuM1ZhL1rDA8d0rHZvtxKJDLz3uIRuppj +7wIDAQAB +-----END PUBLIC KEY----- diff --git a/test/template/app.json b/test/template/app.json index faf5779d..a4b29d25 100644 --- a/test/template/app.json +++ b/test/template/app.json @@ -19,11 +19,13 @@ { "ios": { "CodePushDeploymentKey": "mock-ios-deployment-key", - "CodePushServerURL": "http://127.0.0.1:3000" + "CodePushServerURL": "http://127.0.0.1:3000", + "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}" }, "android": { "CodePushDeploymentKey": "mock-android-deployment-key", - "CodePushServerURL": "http://10.0.2.2:3001" + "CodePushServerURL": "http://10.0.2.2:3001", + "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}" } } ] diff --git a/test/template/scenarios/scenarioSyncRestart2x.js b/test/template/scenarios/scenarioSyncRestart2x.js new file mode 100644 index 00000000..9094a0d9 --- /dev/null +++ b/test/template/scenarios/scenarioSyncRestart2x.js @@ -0,0 +1,22 @@ +var CodePushWrapper = require("../codePushWrapper.js"); +import CodePush from "@bitrise/code-push-sdk"; + +module.exports = { + startTest: function (testApp) { + testApp.sendCurrentAndPendingPackage() + .then(() => { + CodePushWrapper.sync(testApp, (status) => { + if (status === CodePush.SyncStatus.UPDATE_INSTALLED) { + testApp.sendCurrentAndPendingPackage().then(() => { + // Call sync() again without restarting: the update from the first call is still pending. + CodePushWrapper.sync(testApp, () => {}, undefined, { installMode: CodePush.InstallMode.ON_NEXT_RESTART }); + }); + } + }, undefined, { installMode: CodePush.InstallMode.ON_NEXT_RESTART }); + }); + }, + + getScenarioName: function () { + return "Sync Restart 2x (no restart in between)"; + } +}; diff --git a/test/test.ts b/test/test.ts index c117014f..579e4c60 100644 --- a/test/test.ts +++ b/test/test.ts @@ -7,6 +7,7 @@ import mkdirp = require("mkdirp"); import os = require("os"); import path = require("path"); import slash = require("slash"); +import { promisify } from "util"; import { Platform, PluginTestingFramework, ProjectManager, setupTestRunScenario, setupUpdateScenario, ServerUtil, TestBuilder, TestConfig, TestUtil } from "code-push-plugin-testing-framework"; @@ -14,6 +15,11 @@ import Q = require("q"); import del = require("del"); +import { codeSigningPublicKey, signAndRecordUpdateArchive, setupTamperedSignatureUpdateScenario } from "./codesign"; + +// Used in test/template/app.json to avoid duplicating the PEM fixture in two places (ios and android plugin config). +const CODE_SIGNING_PUBLIC_KEY_PLACEHOLDER = "{{CODE_SIGNING_PUBLIC_KEY}}"; + function ensureAndroidCleartextTraffic(androidManifestPath: string): void { const androidManifestContents = fs.readFileSync(androidManifestPath, "utf8"); @@ -36,6 +42,10 @@ function ensureAndroidCleartextTraffic(androidManifestPath: string): void { } } +async function setPlistStringValue(plistPath: string, key: string, value: string): Promise { + await promisify(childProcess.execFile)("plutil", ["-replace", key, "-string", value, plistPath]); +} + /** * Returns a " --platform " flag for `expo prebuild` when exactly one platform is * under test in this mocha run, so prebuild only regenerates that platform's native project @@ -167,6 +177,7 @@ class RNAndroid extends Platform.Android implements RNPlatform { const string = path.join(innerprojectDirectory, "android", "app", "src", "main", "res", "values", "strings.xml"); TestUtil.replaceString(string, TestUtil.SERVER_URL_PLACEHOLDER, this.getServerUrl()); TestUtil.replaceString(string, TestUtil.ANDROID_KEY_PLACEHOLDER, this.getDefaultDeploymentKey()); + TestUtil.replaceString(string, "", `${codeSigningPublicKey}\n`); TestUtil.replaceString(AndroidManifest, "\\${usesCleartextTraffic}", "true"); @@ -238,14 +249,10 @@ class RNIOS extends Platform.IOS implements RNPlatform { // Install the Podfile return TestUtil.copyFile(path.join(TestConfig.templatePath, "ios", "Podfile"), podfilePath, true) .then(() => TestUtil.getProcessOutput(`pod install`, { cwd: iOSProject, noLogStdOut: true })) - // Put the IOS deployment key in the Info.plist - .then(TestUtil.replaceString.bind(undefined, infoPlistPath, - "\n", - "CodePushDeploymentKey\n\t" + this.getDefaultDeploymentKey() + "\n\tCodePushServerURL\n\t" + this.getServerUrl() + "\n\t\n")) - // Set the app version to 1.0.0 instead of 1.0 in the Info.plist - .then(TestUtil.replaceString.bind(undefined, infoPlistPath, "1.0", "1.0.0")) - // Remove dependence of CFBundleShortVersionString from project.pbxproj - .then(TestUtil.replaceString.bind(undefined, infoPlistPath, "\\$\\(MARKETING_VERSION\\)", "1.0.0")) + .then(() => setPlistStringValue(infoPlistPath, "CFBundleShortVersionString", "1.0.0")) + .then(() => setPlistStringValue(infoPlistPath, "CodePushDeploymentKey", this.getDefaultDeploymentKey())) + .then(() => setPlistStringValue(infoPlistPath, "CodePushServerURL", this.getServerUrl())) + .then(() => setPlistStringValue(infoPlistPath, "CodePushPublicKey", codeSigningPublicKey)) // Fix the linker flag list in project.pbxproj (pod install adds an extra comma) .then(TestUtil.replaceString.bind(undefined, path.join(iOSProject, TestConfig.TestAppName + ".xcodeproj", "project.pbxproj"), "\"[$][(]inherited[)]\",\\s*[)];", "\"$(inherited)\"\n\t\t\t\t);")) @@ -398,6 +405,12 @@ class RNProjectManager extends ProjectManager { return TestUtil.getProcessOutput(`npx create-expo-app@latest ${appName} --template blank@sdk-57`, { cwd: projectDirectory, timeout: 30 * 60 * 1000, noLogStdOut: true }) .then((e) => { console.log(`"npx expo init ${appName}" success. cwd=${projectDirectory}`); return e; }) .then(this.copyTemplate.bind(this, templatePath, projectDirectory)) + .then(() => { + const appJsonPath = path.join(projectDirectory, TestConfig.TestAppName, "app.json"); + // app.json is JSON, so the PEM's line breaks must stay escaped rather than literal. + const escapedPublicKey = codeSigningPublicKey.replace(/\n/g, "\\n"); + TestUtil.replaceString(appJsonPath, CODE_SIGNING_PUBLIC_KEY_PLACEHOLDER, escapedPublicKey); + }) .then(TestUtil.getProcessOutput.bind(undefined, TestConfig.thisPluginInstallString, { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true, noLogStdErr: true })) .then(installExpoBundleTooling.bind(undefined, path.join(projectDirectory, TestConfig.TestAppName))) // create-expo-app's blank template ships without a metro.config.js. react-native-xcode.sh's @@ -497,12 +510,14 @@ class RNProjectManager extends ProjectManager { .then(TestUtil.getProcessOutput.bind(undefined, "npx expo prebuild --platform " + targetPlatform.getName(), { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) + .then(() => signAndRecordUpdateArchive(bundleFolder, isDiff)) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } else { return deferred.promise .then(TestUtil.getProcessOutput.bind(undefined, "npx react-native bundle --entry-file index.js --platform " + targetPlatform.getName() + " --bundle-output " + bundlePath + " --assets-dest " + bundleFolder + " --dev false", { cwd: path.join(projectDirectory, TestConfig.TestAppName), noLogStdOut: true })) + .then(() => signAndRecordUpdateArchive(bundleFolder, isDiff)) .then(TestUtil.archiveFolder.bind(undefined, bundleFolder, "", path.join(projectDirectory, TestConfig.TestAppName, "update.zip"), isDiff)) .then((result) => { console.log(`[TIMING] createUpdateArchive(${projectDirectory}, ${targetPlatform.getName()}) took ${Date.now() - t0}ms`); return result; }); } @@ -616,6 +631,7 @@ const ScenarioSyncResumeDelay = "scenarioSyncResumeDelay.js"; const ScenarioSyncRestartDelay = "scenarioSyncRestartDelay.js"; const ScenarioSyncSuspendDelay = "scenarioSyncSuspendDelay.js"; const ScenarioSync2x = "scenarioSync2x.js"; +const ScenarioSyncRestart2x = "scenarioSyncRestart2x.js"; const ScenarioRestart = "scenarioRestart.js"; const ScenarioRestart2x = "scenarioRestart2x.js"; const ScenarioSyncMandatoryDefault = "scenarioSyncMandatoryDefault.js"; @@ -996,6 +1012,27 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl }); }, ScenarioInstall); + TestBuilder.describe("#localPackage.install.codeSigning", + () => { + TestBuilder.it("localPackage.install.codeSigning.tamperedSignature", false, + async (done: Mocha.Done) => { + try { + ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; + + /* create a normal update, then tamper with its signature after it's been signed */ + const updatePath = await setupTamperedSignatureUpdateScenario(projectManager, targetPlatform, UpdateNotifyApplicationReady, "Tampered Update"); + ServerUtil.updatePackagePath = updatePath; + projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); + await ServerUtil.expectTestMessages([ + ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, + ServerUtil.TestMessage.DOWNLOAD_ERROR]); + done(); + } catch (e) { + done(e); + } + }); + }, ScenarioInstall); + TestBuilder.describe("#localPackage.install.revert", () => { TestBuilder.it("localPackage.install.revert.dorevert", false, @@ -1013,16 +1050,9 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); }) .then(() => { - /* restart the app to ensure it was reverted and send it another update */ - ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; - targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); - return ServerUtil.expectTestMessages([ - ServerUtil.TestMessage.CHECK_UPDATE_AVAILABLE, - ServerUtil.TestMessage.DOWNLOAD_SUCCEEDED, - ServerUtil.TestMessage.DEVICE_READY_AFTER_UPDATE]); - }) - .then(() => { - /* restart the app again to ensure it was reverted again and send the same update and expect it to reject it */ + /* restart the app to ensure it was reverted; the native rollback path marks + the failed update's hash as failed immediately, so the same update should + now be rejected outright rather than being re-downloaded and retried */ targetPlatform.getEmulatorManager().restartApplication(TestConfig.TestNamespace); return ServerUtil.expectTestMessages([ServerUtil.TestMessage.UPDATE_FAILED_PREVIOUSLY]); }) @@ -1538,6 +1568,31 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl }, ScenarioSync2x); }); + TestBuilder.describe("#window.codePush.sync restart 2x", + () => { + // Regression test: a sync() called again while a previous ON_NEXT_RESTART install is + // still pending (i.e. no actual restart happened in between) must report UPDATE_INSTALLED, + // not UP_TO_DATE. getCurrentPackage().isPending must reflect that pending state. + TestBuilder.it("window.codePush.sync.restart2x.stillpending", false, + (done: Mocha.Done) => { + ServerUtil.updateResponse = { update_info: ServerUtil.createUpdateResponse(false, targetPlatform) }; + + setupUpdateScenario(projectManager, targetPlatform, UpdateDeviceReady, "Update 1 (good update)") + .then((updatePath: string) => { + ServerUtil.updatePackagePath = updatePath; + projectManager.runApplication(TestConfig.testRunDirectory, targetPlatform); + return ServerUtil.expectTestMessages([ + new ServerUtil.AppMessage(ServerUtil.TestMessage.PENDING_PACKAGE, [null]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.CURRENT_PACKAGE, [null]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.SYNC_STATUS, [ServerUtil.TestMessage.SYNC_UPDATE_INSTALLED]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.PENDING_PACKAGE, [ServerUtil.updateResponse.update_info.package_hash]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.CURRENT_PACKAGE, [null]), + new ServerUtil.AppMessage(ServerUtil.TestMessage.SYNC_STATUS, [ServerUtil.TestMessage.SYNC_UPDATE_INSTALLED])]); + }) + .done(() => { done(); }, (e) => { done(e); }); + }); + }, ScenarioSyncRestart2x); + TestBuilder.describe("#window.codePush.sync minimum background duration tests", () => { TestBuilder.it("defaults to no minimum for Resume mode", false,