|
| 1 | +package com.microsoft.codepush.react |
| 2 | + |
| 3 | +import android.util.Log |
| 4 | +import org.json.JSONObject |
| 5 | +import org.junit.After |
| 6 | +import org.junit.Assert.assertEquals |
| 7 | +import org.junit.Assert.assertFalse |
| 8 | +import org.junit.Assert.assertTrue |
| 9 | +import org.junit.Assert.fail |
| 10 | +import org.junit.Before |
| 11 | +import org.junit.Rule |
| 12 | +import org.junit.Test |
| 13 | +import org.junit.rules.TemporaryFolder |
| 14 | +import org.mockito.MockedStatic |
| 15 | +import org.mockito.Mockito |
| 16 | +import java.io.File |
| 17 | +import java.util.zip.ZipEntry |
| 18 | +import java.util.zip.ZipOutputStream |
| 19 | + |
| 20 | +class CodePushUpdateManagerTest { |
| 21 | + |
| 22 | + @get:Rule |
| 23 | + val tempFolder = TemporaryFolder() |
| 24 | + |
| 25 | + private lateinit var logMock: MockedStatic<Log> |
| 26 | + |
| 27 | + @Before |
| 28 | + fun mockAndroidLog() { |
| 29 | + // CodePushUtils.log() is used deep inside the SDK classes, which isn't stubbed for plain JVM unit tests. |
| 30 | + // We'd rather hack around this (as long there is nothing else to mock) than moving these tests to instrumented Android tests. |
| 31 | + logMock = Mockito.mockStatic(Log::class.java) |
| 32 | + } |
| 33 | + |
| 34 | + @After |
| 35 | + fun unmockAndroidLog() { |
| 36 | + logMock.close() |
| 37 | + } |
| 38 | + |
| 39 | + private fun manager(enableDeltaUpdates: Boolean = false) = |
| 40 | + CodePushUpdateManager(tempFolder.newFolder("documents").absolutePath, enableDeltaUpdates) |
| 41 | + |
| 42 | + private fun updatePackage(hash: String) = JSONObject().apply { |
| 43 | + put(CodePushConstants.PACKAGE_HASH_KEY, hash) |
| 44 | + } |
| 45 | + |
| 46 | + private fun zipOf(vararg entries: Pair<String, String>): File { |
| 47 | + val zipFile = tempFolder.newFile("download.zip") |
| 48 | + ZipOutputStream(zipFile.outputStream()).use { zip -> |
| 49 | + for ((path, content) in entries) { |
| 50 | + zip.putNextEntry(ZipEntry(path)) |
| 51 | + zip.write(content.toByteArray()) |
| 52 | + zip.closeEntry() |
| 53 | + } |
| 54 | + } |
| 55 | + return zipFile |
| 56 | + } |
| 57 | + |
| 58 | + private fun rawBundleFile(content: String): File { |
| 59 | + val file = tempFolder.newFile("download.bundle") |
| 60 | + file.writeText(content) |
| 61 | + return file |
| 62 | + } |
| 63 | + |
| 64 | + // Registers `hash` as the currently installed package, with the given file contents, so that |
| 65 | + // getCurrentPackageFolderPath() resolves to it. Needed to set up diff-update scenarios. |
| 66 | + private fun installCurrentPackage(update: CodePushUpdateManager, hash: String, files: Map<String, String>): String { |
| 67 | + val folderPath = update.getPackageFolderPath(hash) |
| 68 | + File(folderPath).mkdirs() |
| 69 | + for ((relativePath, content) in files) { |
| 70 | + val file = File(folderPath, relativePath) |
| 71 | + file.parentFile?.mkdirs() |
| 72 | + file.writeText(content) |
| 73 | + } |
| 74 | + update.updateCurrentPackageInfo(JSONObject().apply { put(CodePushConstants.CURRENT_PACKAGE_KEY, hash) }) |
| 75 | + return folderPath |
| 76 | + } |
| 77 | + |
| 78 | + @Test |
| 79 | + fun installDownloadedUpdate_rawBundle_movesFileIntoPlaceAndWritesMetadataWithoutBundlePath() { |
| 80 | + // Given |
| 81 | + val update = manager() |
| 82 | + val pkg = updatePackage("hash1") |
| 83 | + val downloadFile = rawBundleFile("raw jsbundle contents") |
| 84 | + val newUpdateFolderPath = update.getPackageFolderPath("hash1") |
| 85 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 86 | + |
| 87 | + // When |
| 88 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, false, newUpdateFolderPath, newUpdateMetadataPath) |
| 89 | + |
| 90 | + // Then |
| 91 | + val installedBundle = File(newUpdateFolderPath, "index.android.bundle") |
| 92 | + assertTrue(installedBundle.exists()) |
| 93 | + assertEquals("raw jsbundle contents", installedBundle.readText()) |
| 94 | + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) |
| 95 | + assertEquals("hash1", metadata.getString(CodePushConstants.PACKAGE_HASH_KEY)) |
| 96 | + assertFalse("raw bundle updates never set a bundlePath", metadata.has(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY)) |
| 97 | + } |
| 98 | + |
| 99 | + @Test |
| 100 | + fun installDownloadedUpdate_zipFullUpdate_findsBundleInNestedFolderAndRecordsItsRelativePath() { |
| 101 | + // Given |
| 102 | + val update = manager() |
| 103 | + val entries = arrayOf( |
| 104 | + "sub/index.android.bundle" to "new bundle contents", |
| 105 | + "sub/asset.png" to "fake asset bytes", |
| 106 | + ) |
| 107 | + val downloadFile = zipOf(*entries) |
| 108 | + val pkg = updatePackage("ff53f424bd583841638ff4e65f32dd71944ba72022d27ad6b8d8db8401b5bbf2") |
| 109 | + val newUpdateFolderPath = update.getPackageFolderPath("hash2") |
| 110 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 111 | + |
| 112 | + // When |
| 113 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 114 | + |
| 115 | + // Then |
| 116 | + assertEquals("new bundle contents", File(newUpdateFolderPath, "sub/index.android.bundle").readText()) |
| 117 | + val metadata = JSONObject(File(newUpdateMetadataPath).readText()) |
| 118 | + assertEquals( |
| 119 | + CodePushUtils.appendPathComponent("sub", "index.android.bundle"), |
| 120 | + metadata.getString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY), |
| 121 | + ) |
| 122 | + } |
| 123 | + |
| 124 | + @Test |
| 125 | + fun installDownloadedUpdate_zipMissingExpectedBundle_throwsInvalidUpdateException() { |
| 126 | + // Given |
| 127 | + val update = manager() |
| 128 | + val downloadFile = zipOf("other.txt" to "not a bundle") |
| 129 | + val pkg = updatePackage("hash3") |
| 130 | + val newUpdateFolderPath = update.getPackageFolderPath("hash3") |
| 131 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 132 | + |
| 133 | + // When / Then |
| 134 | + try { |
| 135 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 136 | + fail("expected CodePushInvalidUpdateException") |
| 137 | + } catch (e: CodePushInvalidUpdateException) { |
| 138 | + assertTrue(e.message!!.contains("A JS bundle file named \"index.android.bundle\" could not be found")) |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + @Test |
| 143 | + fun installDownloadedUpdate_zipFullUpdateWithNoPublicKeyAndNoSignatureAndWrongHash_throwsInvalidUpdateException() { |
| 144 | + // Given |
| 145 | + val update = manager() |
| 146 | + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") |
| 147 | + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") |
| 148 | + val newUpdateFolderPath = update.getPackageFolderPath("hash4") |
| 149 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 150 | + |
| 151 | + // When / Then |
| 152 | + try { |
| 153 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 154 | + fail("expected CodePushInvalidUpdateException") |
| 155 | + } catch (e: CodePushInvalidUpdateException) { |
| 156 | + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + @Test |
| 161 | + fun installDownloadedUpdate_publicKeyConfiguredButNoSignatureInBundle_throwsInvalidUpdateException() { |
| 162 | + // Given |
| 163 | + val update = manager() |
| 164 | + val downloadFile = zipOf("index.android.bundle" to "new bundle contents") |
| 165 | + val pkg = updatePackage("hash5") |
| 166 | + val newUpdateFolderPath = update.getPackageFolderPath("hash5") |
| 167 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 168 | + |
| 169 | + // When / Then |
| 170 | + try { |
| 171 | + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 172 | + fail("expected CodePushInvalidUpdateException") |
| 173 | + } catch (e: CodePushInvalidUpdateException) { |
| 174 | + assertTrue(e.message!!.contains("Error! Public key was provided but there is no JWT signature within app bundle to verify.")) |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + @Test |
| 179 | + fun installDownloadedUpdate_publicKeyConfiguredAndSignaturePresentButHashMismatch_throwsBeforeSignatureCheck() { |
| 180 | + // Given |
| 181 | + val update = manager() |
| 182 | + val downloadFile = zipOf( |
| 183 | + "index.android.bundle" to "new bundle contents", |
| 184 | + "CodePush/.codepushrelease" to "not-a-real-jwt", |
| 185 | + ) |
| 186 | + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") |
| 187 | + val newUpdateFolderPath = update.getPackageFolderPath("hash6") |
| 188 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 189 | + |
| 190 | + // When / Then |
| 191 | + try { |
| 192 | + update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 193 | + fail("expected CodePushInvalidUpdateException") |
| 194 | + } catch (e: CodePushInvalidUpdateException) { |
| 195 | + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + @Test |
| 200 | + fun installDownloadedUpdate_noPublicKeyButSignaturePresentInBundle_stillVerifiesFolderHash() { |
| 201 | + // Given |
| 202 | + val update = manager() |
| 203 | + val downloadFile = zipOf( |
| 204 | + "index.android.bundle" to "new bundle contents", |
| 205 | + "CodePush/.codepushrelease" to "not-a-real-jwt", |
| 206 | + ) |
| 207 | + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") |
| 208 | + val newUpdateFolderPath = update.getPackageFolderPath("hash7") |
| 209 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 210 | + |
| 211 | + // When / Then |
| 212 | + try { |
| 213 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 214 | + fail("expected CodePushInvalidUpdateException") |
| 215 | + } catch (e: CodePushInvalidUpdateException) { |
| 216 | + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + @Test |
| 221 | + fun installDownloadedUpdate_diffManifestVersionOutOfRange_throwsIOException() { |
| 222 | + // Given |
| 223 | + val update = manager() |
| 224 | + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":3,"deletedFiles":[],"patchedFiles":{}}""") |
| 225 | + val pkg = updatePackage("hash8") |
| 226 | + val newUpdateFolderPath = update.getPackageFolderPath("hash8") |
| 227 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 228 | + |
| 229 | + // When / Then |
| 230 | + try { |
| 231 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 232 | + fail("expected IOException") |
| 233 | + } catch (e: java.io.IOException) { |
| 234 | + assertTrue(e.message!!.contains("Diff manifest version 3 is not supported by this SDK version")) |
| 235 | + } |
| 236 | + } |
| 237 | + |
| 238 | + @Test |
| 239 | + fun installDownloadedUpdate_binaryDiffUpdateWhenDisabledOnClient_throwsIOException() { |
| 240 | + // Given |
| 241 | + val update = manager(enableDeltaUpdates = false) |
| 242 | + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""") |
| 243 | + val pkg = updatePackage("hash9") |
| 244 | + val newUpdateFolderPath = update.getPackageFolderPath("hash9") |
| 245 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 246 | + |
| 247 | + // When / Then |
| 248 | + try { |
| 249 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 250 | + fail("expected IOException") |
| 251 | + } catch (e: java.io.IOException) { |
| 252 | + assertTrue(e.message!!.contains("Received a binary diff update, but delta updates are not enabled on this client.")) |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + @Test |
| 257 | + fun installDownloadedUpdate_binaryDiffUpdateWithNoCurrentPackageInstalled_throwsInvalidUpdateException() { |
| 258 | + // Given |
| 259 | + val update = manager(enableDeltaUpdates = true) |
| 260 | + val downloadFile = zipOf(CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":2,"deletedFiles":[],"patchedFiles":{}}""") |
| 261 | + val pkg = updatePackage("hash10") |
| 262 | + val newUpdateFolderPath = update.getPackageFolderPath("hash10") |
| 263 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 264 | + |
| 265 | + // When / Then |
| 266 | + try { |
| 267 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 268 | + fail("expected CodePushInvalidUpdateException") |
| 269 | + } catch (e: CodePushInvalidUpdateException) { |
| 270 | + 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.")) |
| 271 | + } |
| 272 | + } |
| 273 | + |
| 274 | + @Test |
| 275 | + fun installDownloadedUpdate_versionOneDiffUpdate_carriesOverKeptFilesDeletesRemovedOnesAndAppliesNewOnes() { |
| 276 | + // Given |
| 277 | + val update = manager() |
| 278 | + installCurrentPackage(update, "current-hash", mapOf( |
| 279 | + "kept.txt" to "kept contents", |
| 280 | + "old_extra.txt" to "stale contents", |
| 281 | + )) |
| 282 | + val downloadFile = zipOf( |
| 283 | + CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":1,"deletedFiles":["old_extra.txt"],"patchedFiles":{}}""", |
| 284 | + "index.android.bundle" to "new bundle contents", |
| 285 | + ) |
| 286 | + // Deliberately wrong, so the folder-hash check at the end of the diff-update path throws - |
| 287 | + // but only after the merge below has already run, so we can still assert on its result. |
| 288 | + val pkg = updatePackage("this-hash-does-not-match-the-real-contents") |
| 289 | + val newUpdateFolderPath = update.getPackageFolderPath("new-hash") |
| 290 | + val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME) |
| 291 | + |
| 292 | + // When / Then |
| 293 | + try { |
| 294 | + update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath) |
| 295 | + fail("expected CodePushInvalidUpdateException from the folder hash check") |
| 296 | + } catch (e: CodePushInvalidUpdateException) { |
| 297 | + assertTrue(e.message!!.contains("The update contents failed the data integrity check.")) |
| 298 | + } |
| 299 | + |
| 300 | + // Then (the merge above already ran, so its filesystem side effects are still checkable) |
| 301 | + assertEquals("kept contents", File(newUpdateFolderPath, "kept.txt").readText()) |
| 302 | + assertFalse("deletedFiles entry should have been removed", File(newUpdateFolderPath, "old_extra.txt").exists()) |
| 303 | + assertEquals("new bundle contents", File(newUpdateFolderPath, "index.android.bundle").readText()) |
| 304 | + assertFalse("the manifest itself should not be carried into the installed package", File(newUpdateFolderPath, CodePushConstants.DIFF_MANIFEST_FILE_NAME).exists()) |
| 305 | + } |
| 306 | +} |
0 commit comments