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 `modern artifact with separate blake3 and sha256 fields is accepted`() {26 27 28 29 val body = """30 {"version":"2.9.19","platforms":{"android-arm64":{31 "url":"https://x/y",32 "blake3":"${"a".repeat(64)}",33 "sha256":"${"b".repeat(64)}"}}}34 """.trimIndent()35 val parsed = parseReleaseManifest(body)36 assertEquals("https://x/y", parsed.url)37 assertEquals(ExpectedChecksum("blake3", "a".repeat(64)), parseChecksum(parsed.checksum))38 }3940 @Test41 fun `artifact with only sha256 falls back to sha256 checksum`() {42 val body = """43 {"version":"2.9.19","platforms":{"android-arm64":{44 "url":"https://x/y","sha256":"${"b".repeat(64)}"}}}45 """.trimIndent()46 val parsed = parseReleaseManifest(body)47 assertEquals(ExpectedChecksum("sha256", "b".repeat(64)), parseChecksum(parsed.checksum))48 }4950 @Test51 fun `artifact with no checksum in any shape is rejected`() {52 val body = """53 {"version":"2.9.19","platforms":{"android-arm64":{"url":"https://x/y"}}}54 """.trimIndent()55 val ex = runCatching { parseReleaseManifest(body) }.exceptionOrNull()56 assertTrue(ex is ManifestFormatException)57 }5859 @Test60 fun `modern artifact with malformed blake3 hex is rejected`() {61 62 63 val body = """64 {"version":"2.9.19","platforms":{"android-arm64":{65 "url":"https://x/y","blake3":"not-hex"}}}66 """.trimIndent()67 val ex = runCatching { parseReleaseManifest(body) }.exceptionOrNull()68 assertTrue(ex is ManifestFormatException)69 }7071 @Test72 fun `canonical notes key wins`() {73 val parsed = parseReleaseManifest(manifest(""""notes": "fixed the thing", "release_notes": "old","""))74 assertEquals("fixed the thing", parsed.notes)75 assertEquals("2.3.68", parsed.version)76 }7778 @Test79 fun `legacy release_notes key still accepted`() {80 assertEquals("legacy", parseReleaseManifest(manifest(""""release_notes": "legacy",""")).notes)81 }8283 @Test84 fun `omitted notes are absent, not the string null`() {85 assertNull(parseReleaseManifest(manifest("")).notes)86 }8788 @Test89 fun `json null notes never render as the text null`() {90 assertNull(parseReleaseManifest(manifest(""""notes": null,""")).notes)91 assertNull(parseReleaseManifest(manifest(""""notes": " ",""")).notes)92 }9394 @Test95 fun `canonical platform artifact is accepted`() {96 97 98 val body = """99 {"version":"2.3.68","platforms":{"android-arm64":100 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}101 """.trimIndent()102 assertEquals("https://x/y", parseReleaseManifest(body).url)103 }104105 @Test(expected = ManifestFormatException::class)106 fun `no platforms is a hard failure`() {107 parseReleaseManifest("""{"version":"2.3.68"}""")108 }109110 @Test(expected = ManifestFormatException::class)111 fun `non-canonical platform key is rejected, no sole-artifact fallback`() {112 113 114 val body = """115 {"version":"2.3.68","platforms":{"android-x86":116 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}117 """.trimIndent()118 parseReleaseManifest(body)119 }120121 122123 @Test124 fun `parseReleaseManifest accepts a clean SemVer version`() {125 val parsed = parseReleaseManifest(manifest(""))126 assertEquals("2.3.68", parsed.version)127 }128129 @Test(expected = ManifestFormatException::class)130 fun `version with only two components is rejected`() {131 parseReleaseManifest(132 """{"version":"2.3","platforms":{"$ANDROID_PLATFORM_KEY":133 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",134 )135 }136137 @Test(expected = ManifestFormatException::class)138 fun `version with four components is rejected`() {139 parseReleaseManifest(140 """{"version":"2.3.68.0","platforms":{"$ANDROID_PLATFORM_KEY":141 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",142 )143 }144145 @Test(expected = ManifestFormatException::class)146 fun `version with path-traversal characters is rejected`() {147 parseReleaseManifest(148 """{"version":"../../../etc/passwd","platforms":{"$ANDROID_PLATFORM_KEY":149 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",150 )151 }152153 @Test(expected = ManifestFormatException::class)154 fun `version with a leading zero segment is rejected`() {155 156 157 parseReleaseManifest(158 """{"version":"02.3.68","platforms":{"$ANDROID_PLATFORM_KEY":159 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",160 )161 }162163 @Test(expected = ManifestFormatException::class)164 fun `version with an overflowing segment is rejected`() {165 166 167 parseReleaseManifest(168 """{"version":"99999999999.0.0","platforms":{"$ANDROID_PLATFORM_KEY":169 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",170 )171 }172173 @Test(expected = ManifestFormatException::class)174 fun `version with non-numeric components is rejected`() {175 parseReleaseManifest(176 """{"version":"2.3.NaN","platforms":{"$ANDROID_PLATFORM_KEY":177 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",178 )179 }180181 182183 @Test184 fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {185 186 187 188 189 assertEquals(50L * 1024 * 1024, MAX_APK_BYTES)190 assertTrue(MAX_APK_BYTES > 0)191 }192193 @Test194 fun `version comparison is numeric, not lexical`() {195 assertTrue(isNewerVersion("2.3.100", "2.3.67"))196 assertTrue(isNewerVersion("2.4.0", "2.3.99"))197 assertTrue(isNewerVersion("10.0.0", "9.9.9"))198 assertFalse(isNewerVersion("2.3.67", "2.3.67"))199 assertFalse(isNewerVersion("2.3.9", "2.3.10"))200 }201202 @Test203 fun `debug suffix compares as the release it was built from`() {204 assertEquals("2.3.67", normalizeVersion("2.3.67-debug"))205 assertEquals("2.3.67", normalizeVersion("v2.3.67"))206 assertFalse(isNewerVersion("2.3.67", "2.3.67-debug"))207 assertTrue(isNewerVersion("2.3.68", "2.3.67-debug"))208 }209210 @Test211 fun `only well-formed blake3 and sha256 checksums are accepted`() {212 assertEquals(213 ExpectedChecksum("blake3", "a".repeat(64)),214 parseChecksum("blake3:${"A".repeat(64)}"),215 )216 assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm)217 assertNull(parseChecksum("md5:${"a".repeat(64)}"))218 assertNull(parseChecksum("blake3:tooshort"))219 assertNull(parseChecksum("blake3:${"z".repeat(64)}"))220 assertNull(parseChecksum("nocolon"))221 }222223 224 @Test225 fun `blake3 matches the reference vectors`() {226 assertEquals(227 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",228 hashStream(ByteArray(0).inputStream(), "blake3"),229 )230 assertEquals(231 "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85",232 hashStream("abc".toByteArray().inputStream(), "blake3"),233 )234 }235236 @Test237 fun `sha256 matches the reference vector`() {238 assertEquals(239 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",240 hashStream("abc".toByteArray().inputStream(), "sha256"),241 )242 }243244 245 @Test246 fun `isExpectedApk accepts exact manifest version with a strictly newer code`() {247 assertTrue(248 isExpectedApk(249 packageName = "com.peachpasswords",250 versionName = "2.8.5",251 versionCode = 240146L,252 expectedPackage = "com.peachpasswords",253 expectedVersionName = "2.8.5",254 currentVersionCode = 240145L,255 ),256 )257 }258259 @Test260 fun `isExpectedApk rejects a foreign package`() {261 assertFalse(262 isExpectedApk(263 "com.evil.twin", "2.8.5", 999_999L,264 "com.peachpasswords", "2.8.5", 240145L,265 ),266 )267 }268269 @Test270 fun `isExpectedApk rejects separately installable debug package`() {271 assertFalse(272 isExpectedApk(273 "com.peachpasswords.debug", "2.8.5", 240200L,274 "com.peachpasswords", "2.8.5", 240145L,275 ),276 )277 }278279 @Test280 fun `isExpectedApk rejects a downgrade`() {281 282 283 284 assertFalse(285 isExpectedApk(286 "com.peachpasswords", "2.8.5", 240145L,287 "com.peachpasswords", "2.8.5", 240145L,288 ),289 )290 }291292 @Test293 fun `isExpectedApk rejects null and blank package names`() {294 assertFalse(295 isExpectedApk(null, "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),296 )297 assertFalse(298 isExpectedApk("", "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),299 )300 }301302 @Test303 fun `isExpectedApk rejects a semantic version mismatch even with a newer code`() {304 assertFalse(305 isExpectedApk(306 "com.peachpasswords", "2.8.6", 240200L,307 "com.peachpasswords", "2.8.5", 240145L,308 ),309 )310 }311312 @Test313 fun `isExpectedApk rejects an equal versionCode replay`() {314 assertFalse(315 isExpectedApk(316 "com.peachpasswords", "2.8.5", 240145L,317 "com.peachpasswords", "2.8.5", 240145L,318 ),319 )320 }321322 323324 @Test325 fun `package version code helper preserves legacy and modern fields`() {326 assertEquals(240145L, packageVersionCodeForSdk(26, 240145, 9_999_999_999L))327 assertEquals(240145L, packageVersionCodeForSdk(27, 240145, 9_999_999_999L))328 assertEquals(9_999_999_999L, packageVersionCodeForSdk(28, 240145, 9_999_999_999L))329 }330331 @Test332 fun `package signing flags helper selects API boundary`() {333 assertEquals(1, packageSigningInfoFlagsForSdk(26, 1, 2))334 assertEquals(1, packageSigningInfoFlagsForSdk(27, 1, 2))335 assertEquals(2, packageSigningInfoFlagsForSdk(28, 1, 2))336 }337338 339340 @Test341 fun `signing lineage accepts unchanged signer`() {342 assertTrue(343 isSigningLineageCompatible(344 archiveCurrent = setOf("current"),345 archiveHistory = setOf("current"),346 installedCurrent = setOf("current"),347 ),348 )349 }350351 @Test352 fun `signing lineage accepts a forward rotation proving the installed signer`() {353 assertTrue(354 isSigningLineageCompatible(355 archiveCurrent = setOf("rotated"),356 archiveHistory = setOf("installed", "rotated"),357 installedCurrent = setOf("installed"),358 ),359 )360 }361362 @Test363 fun `signing lineage rejects reverse rotation even when old signer is historical`() {364 assertFalse(365 isSigningLineageCompatible(366 archiveCurrent = setOf("old"),367 archiveHistory = setOf("old"),368 installedCurrent = setOf("rotated"),369 ),370 )371 }372373 @Test374 fun `signing lineage rejects a signer-set expansion`() {375 assertFalse(376 isSigningLineageCompatible(377 archiveCurrent = setOf("installed", "attacker"),378 archiveHistory = setOf("installed", "attacker"),379 installedCurrent = setOf("installed"),380 ),381 )382 }383384 @Test385 fun `release history parses newest-first with optional notes`() {386 387 val body = """388 [{"version":"2.10.21","notes":"Fixed things."},389 {"version":"2.10.20","notes":""},390 {"version":"2.10.19"}]391 """.trimIndent()392 val history = parseReleaseHistory(body)393 assertEquals(3, history.size)394 assertEquals("2.10.21", history[0].version)395 assertEquals("Fixed things.", history[0].notes)396 assertNull(history[1].notes)397 assertNull(history[2].notes)398 }399}