1package com.peachpasswords.updates23import org.junit.Assert.assertEquals4import org.junit.Assert.assertNull5import org.junit.Assert.assertTrue6import org.junit.Assert.assertFalse7import org.junit.Test89class UpdateManifestTest {1011 private fun manifest(notesField: String) = """12 {13 "version": "2.3.68",14 $notesField15 "platforms": {16 "$ANDROID_PLATFORM_KEY": {17 "url": "https://kepr.uk/api/peach-android/releases/2.3.68/$ANDROID_PLATFORM_KEY",18 "checksum": "blake3:${"a".repeat(64)}"19 }20 }21 }22 """.trimIndent()2324 @Test25 fun `canonical notes key wins`() {26 val parsed = parseReleaseManifest(manifest(""""notes": "fixed the thing", "release_notes": "old","""))27 assertEquals("fixed the thing", parsed.notes)28 assertEquals("2.3.68", parsed.version)29 }3031 @Test32 fun `legacy release_notes key still accepted`() {33 assertEquals("legacy", parseReleaseManifest(manifest(""""release_notes": "legacy",""")).notes)34 }3536 @Test37 fun `omitted notes are absent, not the string null`() {38 assertNull(parseReleaseManifest(manifest("")).notes)39 }4041 @Test42 fun `json null notes never render as the text null`() {43 assertNull(parseReleaseManifest(manifest(""""notes": null,""")).notes)44 assertNull(parseReleaseManifest(manifest(""""notes": " ",""")).notes)45 }4647 @Test48 fun `canonical platform artifact is accepted`() {49 50 51 val body = """52 {"version":"2.3.68","platforms":{"android-arm64":53 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}54 """.trimIndent()55 assertEquals("https://x/y", parseReleaseManifest(body).url)56 }5758 @Test59 fun `platform selection follows the device ABI`() {60 assertEquals(ANDROID_PLATFORM_KEY, androidPlatformKey(arrayOf("arm64-v8a", "armeabi-v7a")))61 assertEquals(ANDROID_X86_PLATFORM_KEY, androidPlatformKey(arrayOf("x86_64", "x86")))62 assertNull(androidPlatformKey(arrayOf("armeabi-v7a")))63 }6465 @Test66 fun `x86 device selects the x86 artifact`() {67 val body = """68 {"version":"2.3.68","platforms":{"android-x86":69 {"url":"https://x/x86.apk","checksum":"sha256:${"b".repeat(64)}"}}}70 """.trimIndent()71 assertEquals(72 "https://x/x86.apk",73 parseReleaseManifest(body, androidPlatformKey(arrayOf("x86_64"))!!).url,74 )75 }7677 @Test(expected = ManifestFormatException::class)78 fun `no platforms is a hard failure`() {79 parseReleaseManifest("""{"version":"2.3.68"}""")80 }8182 @Test(expected = ManifestFormatException::class)83 fun `non-canonical platform key is rejected, no sole-artifact fallback`() {84 85 val body = """86 {"version":"2.3.68","platforms":{"android-x86":87 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}88 """.trimIndent()89 parseReleaseManifest(body)90 }9192 9394 @Test95 fun `parseReleaseManifest accepts a clean SemVer version`() {96 val parsed = parseReleaseManifest(manifest(""))97 assertEquals("2.3.68", parsed.version)98 }99100 @Test(expected = ManifestFormatException::class)101 fun `version with only two components is rejected`() {102 parseReleaseManifest(103 """{"version":"2.3","platforms":{"$ANDROID_PLATFORM_KEY":104 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",105 )106 }107108 @Test(expected = ManifestFormatException::class)109 fun `version with four components is rejected`() {110 parseReleaseManifest(111 """{"version":"2.3.68.0","platforms":{"$ANDROID_PLATFORM_KEY":112 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",113 )114 }115116 @Test(expected = ManifestFormatException::class)117 fun `version with path-traversal characters is rejected`() {118 parseReleaseManifest(119 """{"version":"../../../etc/passwd","platforms":{"$ANDROID_PLATFORM_KEY":120 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",121 )122 }123124 @Test(expected = ManifestFormatException::class)125 fun `version with a leading zero segment is rejected`() {126 127 128 parseReleaseManifest(129 """{"version":"02.3.68","platforms":{"$ANDROID_PLATFORM_KEY":130 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",131 )132 }133134 @Test(expected = ManifestFormatException::class)135 fun `version with an overflowing segment is rejected`() {136 137 138 parseReleaseManifest(139 """{"version":"99999999999.0.0","platforms":{"$ANDROID_PLATFORM_KEY":140 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",141 )142 }143144 @Test(expected = ManifestFormatException::class)145 fun `version with non-numeric components is rejected`() {146 parseReleaseManifest(147 """{"version":"2.3.NaN","platforms":{"$ANDROID_PLATFORM_KEY":148 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",149 )150 }151152 153154 @Test155 fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {156 157 158 159 160 assertEquals(50L * 1024 * 1024, MAX_APK_BYTES)161 assertTrue(MAX_APK_BYTES > 0)162 }163164 @Test165 fun `version comparison is numeric, not lexical`() {166 assertTrue(isNewerVersion("2.3.100", "2.3.67"))167 assertTrue(isNewerVersion("2.4.0", "2.3.99"))168 assertTrue(isNewerVersion("10.0.0", "9.9.9"))169 assertFalse(isNewerVersion("2.3.67", "2.3.67"))170 assertFalse(isNewerVersion("2.3.9", "2.3.10"))171 }172173 @Test174 fun `debug suffix compares as the release it was built from`() {175 assertEquals("2.3.67", normalizeVersion("2.3.67-debug"))176 assertEquals("2.3.67", normalizeVersion("v2.3.67"))177 assertFalse(isNewerVersion("2.3.67", "2.3.67-debug"))178 assertTrue(isNewerVersion("2.3.68", "2.3.67-debug"))179 }180181 @Test182 fun `only well-formed blake3 and sha256 checksums are accepted`() {183 assertEquals(184 ExpectedChecksum("blake3", "a".repeat(64)),185 parseChecksum("blake3:${"A".repeat(64)}"),186 )187 assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm)188 assertNull(parseChecksum("md5:${"a".repeat(64)}"))189 assertNull(parseChecksum("blake3:tooshort"))190 assertNull(parseChecksum("blake3:${"z".repeat(64)}"))191 assertNull(parseChecksum("nocolon"))192 }193194 195 @Test196 fun `blake3 matches the reference vectors`() {197 assertEquals(198 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",199 hashStream(ByteArray(0).inputStream(), "blake3"),200 )201 assertEquals(202 "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85",203 hashStream("abc".toByteArray().inputStream(), "blake3"),204 )205 }206207 @Test208 fun `sha256 matches the reference vector`() {209 assertEquals(210 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",211 hashStream("abc".toByteArray().inputStream(), "sha256"),212 )213 }214215 216 @Test217 fun `isExpectedApk accepts exact manifest version with a strictly newer code`() {218 assertTrue(219 isExpectedApk(220 packageName = "com.peachpasswords",221 versionName = "2.8.5",222 versionCode = 240146L,223 expectedPackage = "com.peachpasswords",224 expectedVersionName = "2.8.5",225 currentVersionCode = 240145L,226 ),227 )228 }229230 @Test231 fun `isExpectedApk rejects a foreign package`() {232 assertFalse(233 isExpectedApk(234 "com.evil.twin", "2.8.5", 999_999L,235 "com.peachpasswords", "2.8.5", 240145L,236 ),237 )238 }239240 @Test241 fun `isExpectedApk rejects separately installable debug package`() {242 assertFalse(243 isExpectedApk(244 "com.peachpasswords.debug", "2.8.5", 240200L,245 "com.peachpasswords", "2.8.5", 240145L,246 ),247 )248 }249250 @Test251 fun `isExpectedApk rejects a downgrade`() {252 253 254 255 assertFalse(256 isExpectedApk(257 "com.peachpasswords", "2.8.5", 240145L,258 "com.peachpasswords", "2.8.5", 240145L,259 ),260 )261 }262263 @Test264 fun `isExpectedApk rejects null and blank package names`() {265 assertFalse(266 isExpectedApk(null, "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),267 )268 assertFalse(269 isExpectedApk("", "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),270 )271 }272273 @Test274 fun `isExpectedApk rejects a semantic version mismatch even with a newer code`() {275 assertFalse(276 isExpectedApk(277 "com.peachpasswords", "2.8.6", 240200L,278 "com.peachpasswords", "2.8.5", 240145L,279 ),280 )281 }282283 @Test284 fun `isExpectedApk rejects an equal versionCode replay`() {285 assertFalse(286 isExpectedApk(287 "com.peachpasswords", "2.8.5", 240145L,288 "com.peachpasswords", "2.8.5", 240145L,289 ),290 )291 }292293 294295 @Test296 fun `package version code helper preserves legacy and modern fields`() {297 assertEquals(240145L, packageVersionCodeForSdk(26, 240145, 9_999_999_999L))298 assertEquals(240145L, packageVersionCodeForSdk(27, 240145, 9_999_999_999L))299 assertEquals(9_999_999_999L, packageVersionCodeForSdk(28, 240145, 9_999_999_999L))300 }301302 @Test303 fun `package signing flags helper selects API boundary`() {304 assertEquals(1, packageSigningInfoFlagsForSdk(26, 1, 2))305 assertEquals(1, packageSigningInfoFlagsForSdk(27, 1, 2))306 assertEquals(2, packageSigningInfoFlagsForSdk(28, 1, 2))307 }308309 310311 @Test312 fun `signing lineage accepts unchanged signer`() {313 assertTrue(314 isSigningLineageCompatible(315 archiveCurrent = setOf("current"),316 archiveHistory = setOf("current"),317 installedCurrent = setOf("current"),318 ),319 )320 }321322 @Test323 fun `signing lineage accepts a forward rotation proving the installed signer`() {324 assertTrue(325 isSigningLineageCompatible(326 archiveCurrent = setOf("rotated"),327 archiveHistory = setOf("installed", "rotated"),328 installedCurrent = setOf("installed"),329 ),330 )331 }332333 @Test334 fun `signing lineage rejects reverse rotation even when old signer is historical`() {335 assertFalse(336 isSigningLineageCompatible(337 archiveCurrent = setOf("old"),338 archiveHistory = setOf("old"),339 installedCurrent = setOf("rotated"),340 ),341 )342 }343344 @Test345 fun `signing lineage rejects a signer-set expansion`() {346 assertFalse(347 isSigningLineageCompatible(348 archiveCurrent = setOf("installed", "attacker"),349 archiveHistory = setOf("installed", "attacker"),350 installedCurrent = setOf("installed"),351 ),352 )353 }354}