Skip to content

Commit c6ce5a2

Browse files
committed
Android: unit tests for package install codepaths
1 parent 316a439 commit c6ce5a2

3 files changed

Lines changed: 316 additions & 0 deletions

File tree

android/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ dependencies {
9797

9898
testImplementation 'junit:junit:4.13.2'
9999
testImplementation 'org.json:json:20231013'
100+
testImplementation 'org.mockito:mockito-core:5.14.2'
100101

101102
androidTestImplementation 'junit:junit:4.13.2'
102103
androidTestImplementation 'androidx.test.ext:junit:1.2.1'

android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,15 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN
234234
}
235235
}
236236

237+
installDownloadedUpdate(updatePackage, expectedBundleFileName, stringPublicKey,
238+
downloadFile, isZip, newUpdateFolderPath, newUpdateMetadataPath);
239+
}
240+
241+
void installDownloadedUpdate(JSONObject updatePackage, String expectedBundleFileName,
242+
String stringPublicKey, File downloadFile, boolean isZip,
243+
String newUpdateFolderPath, String newUpdateMetadataPath) throws IOException {
244+
String newUpdateHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null);
245+
237246
if (isZip) {
238247
// Unzip the downloaded file and then delete the zip
239248
String unzippedFolderPath = getUnzippedFolderPath();
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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

Comments
 (0)