Skip to content

Commit 182cec5

Browse files
committed
Android: unit tests for package install codepaths
1 parent b144438 commit 182cec5

3 files changed

Lines changed: 262 additions & 0 deletions

File tree

android/app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ dependencies {
9696
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3'
9797

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

100102
androidTestImplementation 'junit:junit:4.13.2'
101103
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: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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 single instance (as long as this is the only one) 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() = CodePushUpdateManager(tempFolder.newFolder("documents").absolutePath)
40+
41+
private fun updatePackage(hash: String) = JSONObject().apply {
42+
put(CodePushConstants.PACKAGE_HASH_KEY, hash)
43+
}
44+
45+
private fun zipOf(vararg entries: Pair<String, String>): File {
46+
val zipFile = tempFolder.newFile("download.zip")
47+
ZipOutputStream(zipFile.outputStream()).use { zip ->
48+
for ((path, content) in entries) {
49+
zip.putNextEntry(ZipEntry(path))
50+
zip.write(content.toByteArray())
51+
zip.closeEntry()
52+
}
53+
}
54+
return zipFile
55+
}
56+
57+
private fun rawBundleFile(content: String): File {
58+
val file = tempFolder.newFile("download.bundle")
59+
file.writeText(content)
60+
return file
61+
}
62+
63+
// Registers `hash` as the currently installed package, with the given file contents, so that
64+
// getCurrentPackageFolderPath() resolves to it. Needed to set up diff-update scenarios.
65+
private fun installCurrentPackage(update: CodePushUpdateManager, hash: String, files: Map<String, String>): String {
66+
val folderPath = update.getPackageFolderPath(hash)
67+
File(folderPath).mkdirs()
68+
for ((relativePath, content) in files) {
69+
val file = File(folderPath, relativePath)
70+
file.parentFile?.mkdirs()
71+
file.writeText(content)
72+
}
73+
update.updateCurrentPackageInfo(JSONObject().apply { put(CodePushConstants.CURRENT_PACKAGE_KEY, hash) })
74+
return folderPath
75+
}
76+
77+
@Test
78+
fun installDownloadedUpdate_rawBundle_movesFileIntoPlaceAndWritesMetadataWithoutBundlePath() {
79+
// Given
80+
val update = manager()
81+
val pkg = updatePackage("hash1")
82+
val downloadFile = rawBundleFile("raw jsbundle contents")
83+
val newUpdateFolderPath = update.getPackageFolderPath("hash1")
84+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
85+
86+
// When
87+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, false, newUpdateFolderPath, newUpdateMetadataPath)
88+
89+
// Then
90+
val installedBundle = File(newUpdateFolderPath, "index.android.bundle")
91+
assertTrue(installedBundle.exists())
92+
assertEquals("raw jsbundle contents", installedBundle.readText())
93+
val metadata = JSONObject(File(newUpdateMetadataPath).readText())
94+
assertEquals("hash1", metadata.getString(CodePushConstants.PACKAGE_HASH_KEY))
95+
assertFalse("raw bundle updates never set a bundlePath", metadata.has(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY))
96+
}
97+
98+
@Test
99+
fun installDownloadedUpdate_zipFullUpdate_findsBundleInNestedFolderAndRecordsItsRelativePath() {
100+
// Given
101+
val update = manager()
102+
val entries = arrayOf(
103+
"sub/index.android.bundle" to "new bundle contents",
104+
"sub/asset.png" to "fake asset bytes",
105+
)
106+
val downloadFile = zipOf(*entries)
107+
val pkg = updatePackage("ff53f424bd583841638ff4e65f32dd71944ba72022d27ad6b8d8db8401b5bbf2")
108+
val newUpdateFolderPath = update.getPackageFolderPath("hash2")
109+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
110+
111+
// When
112+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
113+
114+
// Then
115+
assertEquals("new bundle contents", File(newUpdateFolderPath, "sub/index.android.bundle").readText())
116+
val metadata = JSONObject(File(newUpdateMetadataPath).readText())
117+
assertEquals(
118+
CodePushUtils.appendPathComponent("sub", "index.android.bundle"),
119+
metadata.getString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY),
120+
)
121+
}
122+
123+
@Test
124+
fun installDownloadedUpdate_zipMissingExpectedBundle_throwsInvalidUpdateException() {
125+
// Given
126+
val update = manager()
127+
val downloadFile = zipOf("other.txt" to "not a bundle")
128+
val pkg = updatePackage("hash3")
129+
val newUpdateFolderPath = update.getPackageFolderPath("hash3")
130+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
131+
132+
// When / Then
133+
try {
134+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
135+
fail("expected CodePushInvalidUpdateException")
136+
} catch (e: CodePushInvalidUpdateException) {
137+
assertTrue(e.message!!.contains("A JS bundle file named \"index.android.bundle\" could not be found"))
138+
}
139+
}
140+
141+
@Test
142+
fun installDownloadedUpdate_zipFullUpdateWithNoPublicKeyAndNoSignatureAndWrongHash_throwsInvalidUpdateException() {
143+
// Given
144+
val update = manager()
145+
val downloadFile = zipOf("index.android.bundle" to "new bundle contents")
146+
val pkg = updatePackage("this-hash-does-not-match-the-real-contents")
147+
val newUpdateFolderPath = update.getPackageFolderPath("hash4")
148+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
149+
150+
// When / Then
151+
try {
152+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
153+
fail("expected CodePushInvalidUpdateException")
154+
} catch (e: CodePushInvalidUpdateException) {
155+
assertTrue(e.message!!.contains("The update contents failed the data integrity check."))
156+
}
157+
}
158+
159+
@Test
160+
fun installDownloadedUpdate_publicKeyConfiguredButNoSignatureInBundle_throwsInvalidUpdateException() {
161+
// Given
162+
val update = manager()
163+
val downloadFile = zipOf("index.android.bundle" to "new bundle contents")
164+
val pkg = updatePackage("hash5")
165+
val newUpdateFolderPath = update.getPackageFolderPath("hash5")
166+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
167+
168+
// When / Then
169+
try {
170+
update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
171+
fail("expected CodePushInvalidUpdateException")
172+
} catch (e: CodePushInvalidUpdateException) {
173+
assertTrue(e.message!!.contains("Error! Public key was provided but there is no JWT signature within app bundle to verify."))
174+
}
175+
}
176+
177+
@Test
178+
fun installDownloadedUpdate_publicKeyConfiguredAndSignaturePresentButHashMismatch_throwsBeforeSignatureCheck() {
179+
// Given
180+
val update = manager()
181+
val downloadFile = zipOf(
182+
"index.android.bundle" to "new bundle contents",
183+
"CodePush/.codepushrelease" to "not-a-real-jwt",
184+
)
185+
val pkg = updatePackage("this-hash-does-not-match-the-real-contents")
186+
val newUpdateFolderPath = update.getPackageFolderPath("hash6")
187+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
188+
189+
// When / Then
190+
try {
191+
update.installDownloadedUpdate(pkg, "index.android.bundle", "dummy-public-key", downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
192+
fail("expected CodePushInvalidUpdateException")
193+
} catch (e: CodePushInvalidUpdateException) {
194+
assertTrue(e.message!!.contains("The update contents failed the data integrity check."))
195+
}
196+
}
197+
198+
@Test
199+
fun installDownloadedUpdate_noPublicKeyButSignaturePresentInBundle_stillVerifiesFolderHash() {
200+
// Given
201+
val update = manager()
202+
val downloadFile = zipOf(
203+
"index.android.bundle" to "new bundle contents",
204+
"CodePush/.codepushrelease" to "not-a-real-jwt",
205+
)
206+
val pkg = updatePackage("this-hash-does-not-match-the-real-contents")
207+
val newUpdateFolderPath = update.getPackageFolderPath("hash7")
208+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
209+
210+
// When / Then
211+
try {
212+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
213+
fail("expected CodePushInvalidUpdateException")
214+
} catch (e: CodePushInvalidUpdateException) {
215+
assertTrue(e.message!!.contains("The update contents failed the data integrity check."))
216+
}
217+
}
218+
219+
@Test
220+
fun installDownloadedUpdate_versionOneDiffUpdate_carriesOverKeptFilesDeletesRemovedOnesAndAppliesNewOnes() {
221+
// Given
222+
val update = manager()
223+
installCurrentPackage(update, "current-hash", mapOf(
224+
"kept.txt" to "kept contents",
225+
"old_extra.txt" to "stale contents",
226+
))
227+
val downloadFile = zipOf(
228+
CodePushConstants.DIFF_MANIFEST_FILE_NAME to """{"version":1,"deletedFiles":["old_extra.txt"],"patchedFiles":{}}""",
229+
"index.android.bundle" to "new bundle contents",
230+
)
231+
// Deliberately wrong, so the folder-hash check at the end of the diff-update path throws -
232+
// but only after the merge below has already run, so we can still assert on its result.
233+
val pkg = updatePackage("this-hash-does-not-match-the-real-contents")
234+
val newUpdateFolderPath = update.getPackageFolderPath("new-hash")
235+
val newUpdateMetadataPath = CodePushUtils.appendPathComponent(newUpdateFolderPath, CodePushConstants.PACKAGE_FILE_NAME)
236+
237+
// When / Then
238+
try {
239+
update.installDownloadedUpdate(pkg, "index.android.bundle", null, downloadFile, true, newUpdateFolderPath, newUpdateMetadataPath)
240+
fail("expected CodePushInvalidUpdateException from the folder hash check")
241+
} catch (e: CodePushInvalidUpdateException) {
242+
assertTrue(e.message!!.contains("The update contents failed the data integrity check."))
243+
}
244+
245+
// Then (the merge above already ran, so its filesystem side effects are still checkable)
246+
assertEquals("kept contents", File(newUpdateFolderPath, "kept.txt").readText())
247+
assertFalse("deletedFiles entry should have been removed", File(newUpdateFolderPath, "old_extra.txt").exists())
248+
assertEquals("new bundle contents", File(newUpdateFolderPath, "index.android.bundle").readText())
249+
assertFalse("the manifest itself should not be carried into the installed package", File(newUpdateFolderPath, CodePushConstants.DIFF_MANIFEST_FILE_NAME).exists())
250+
}
251+
}

0 commit comments

Comments
 (0)