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 @Test(expected = ManifestFormatException::class)95 fun `non HTTPS artifact URL is rejected`() {96 parseReleaseManifest(97 """{"version":"2.3.68","platforms":{"android-arm64":{"url":"http://x/y","checksum":"sha256:${"b".repeat(64)}"}}}""",98 )99 }100101 @Test102 fun `canonical platform artifact is accepted`() {103 104 105 val body = """106 {"version":"2.3.68","platforms":{"android-arm64":107 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}108 """.trimIndent()109 assertEquals("https://x/y", parseReleaseManifest(body).url)110 }111112 @Test(expected = ManifestFormatException::class)113 fun `no platforms is a hard failure`() {114 parseReleaseManifest("""{"version":"2.3.68"}""")115 }116117 @Test(expected = ManifestFormatException::class)118 fun `non-canonical platform key is rejected, no sole-artifact fallback`() {119 120 121 val body = """122 {"version":"2.3.68","platforms":{"android-x86":123 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}124 """.trimIndent()125 parseReleaseManifest(body)126 }127128 129130 @Test131 fun `parseReleaseManifest accepts a clean SemVer version`() {132 val parsed = parseReleaseManifest(manifest(""))133 assertEquals("2.3.68", parsed.version)134 }135136 @Test(expected = ManifestFormatException::class)137 fun `version with only two components is rejected`() {138 parseReleaseManifest(139 """{"version":"2.3","platforms":{"$ANDROID_PLATFORM_KEY":140 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",141 )142 }143144 @Test(expected = ManifestFormatException::class)145 fun `version with four components is rejected`() {146 parseReleaseManifest(147 """{"version":"2.3.68.0","platforms":{"$ANDROID_PLATFORM_KEY":148 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",149 )150 }151152 @Test(expected = ManifestFormatException::class)153 fun `version with path-traversal characters is rejected`() {154 parseReleaseManifest(155 """{"version":"../../../etc/passwd","platforms":{"$ANDROID_PLATFORM_KEY":156 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",157 )158 }159160 @Test(expected = ManifestFormatException::class)161 fun `version with a leading zero segment is rejected`() {162 163 164 parseReleaseManifest(165 """{"version":"02.3.68","platforms":{"$ANDROID_PLATFORM_KEY":166 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",167 )168 }169170 @Test(expected = ManifestFormatException::class)171 fun `version with an overflowing segment is rejected`() {172 173 174 parseReleaseManifest(175 """{"version":"99999999999.0.0","platforms":{"$ANDROID_PLATFORM_KEY":176 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",177 )178 }179180 @Test(expected = ManifestFormatException::class)181 fun `version with non-numeric components is rejected`() {182 parseReleaseManifest(183 """{"version":"2.3.NaN","platforms":{"$ANDROID_PLATFORM_KEY":184 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",185 )186 }187188 189190 @Test191 fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {192 193 194 195 196 assertEquals(50L * 1024 * 1024, MAX_APK_BYTES)197 assertTrue(MAX_APK_BYTES > 0)198 }199200 @Test201 fun `version comparison is numeric, not lexical`() {202 assertTrue(isNewerVersion("2.3.100", "2.3.67"))203 assertTrue(isNewerVersion("2.4.0", "2.3.99"))204 assertTrue(isNewerVersion("10.0.0", "9.9.9"))205 assertFalse(isNewerVersion("2.3.67", "2.3.67"))206 assertFalse(isNewerVersion("2.3.9", "2.3.10"))207 }208209 @Test210 fun `debug suffix compares as the release it was built from`() {211 assertEquals("2.3.67", normalizeVersion("2.3.67-debug"))212 assertEquals("2.3.67", normalizeVersion("v2.3.67"))213 assertFalse(isNewerVersion("2.3.67", "2.3.67-debug"))214 assertTrue(isNewerVersion("2.3.68", "2.3.67-debug"))215 }216217 @Test218 fun `only well-formed blake3 and sha256 checksums are accepted`() {219 assertEquals(220 ExpectedChecksum("blake3", "a".repeat(64)),221 parseChecksum("blake3:${"A".repeat(64)}"),222 )223 assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm)224 assertNull(parseChecksum("md5:${"a".repeat(64)}"))225 assertNull(parseChecksum("blake3:tooshort"))226 assertNull(parseChecksum("blake3:${"z".repeat(64)}"))227 assertNull(parseChecksum("nocolon"))228 }229230 231 @Test232 fun `blake3 matches the reference vectors`() {233 assertEquals(234 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",235 hashStream(ByteArray(0).inputStream(), "blake3"),236 )237 assertEquals(238 "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85",239 hashStream("abc".toByteArray().inputStream(), "blake3"),240 )241 }242243 @Test244 fun `sha256 matches the reference vector`() {245 assertEquals(246 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",247 hashStream("abc".toByteArray().inputStream(), "sha256"),248 )249 }250251 252 @Test253 fun `isExpectedApk accepts exact manifest version with a strictly newer code`() {254 assertTrue(255 isExpectedApk(256 packageName = "com.peachpasswords",257 versionName = "2.8.5",258 versionCode = 240146L,259 expectedPackage = "com.peachpasswords",260 expectedVersionName = "2.8.5",261 currentVersionCode = 240145L,262 ),263 )264 }265266 @Test267 fun `isExpectedApk rejects a foreign package`() {268 assertFalse(269 isExpectedApk(270 "com.evil.twin", "2.8.5", 999_999L,271 "com.peachpasswords", "2.8.5", 240145L,272 ),273 )274 }275276 @Test277 fun `isExpectedApk rejects separately installable debug package`() {278 assertFalse(279 isExpectedApk(280 "com.peachpasswords.debug", "2.8.5", 240200L,281 "com.peachpasswords", "2.8.5", 240145L,282 ),283 )284 }285286 @Test287 fun `isExpectedApk rejects a downgrade`() {288 289 290 291 assertFalse(292 isExpectedApk(293 "com.peachpasswords", "2.8.5", 240145L,294 "com.peachpasswords", "2.8.5", 240145L,295 ),296 )297 }298299 @Test300 fun `isExpectedApk rejects null and blank package names`() {301 assertFalse(302 isExpectedApk(null, "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),303 )304 assertFalse(305 isExpectedApk("", "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),306 )307 }308309 @Test310 fun `isExpectedApk rejects a semantic version mismatch even with a newer code`() {311 assertFalse(312 isExpectedApk(313 "com.peachpasswords", "2.8.6", 240200L,314 "com.peachpasswords", "2.8.5", 240145L,315 ),316 )317 }318319 @Test320 fun `isExpectedApk rejects an equal versionCode replay`() {321 assertFalse(322 isExpectedApk(323 "com.peachpasswords", "2.8.5", 240145L,324 "com.peachpasswords", "2.8.5", 240145L,325 ),326 )327 }328329 330331 @Test332 fun `package version code helper preserves legacy and modern fields`() {333 assertEquals(240145L, packageVersionCodeForSdk(26, 240145, 9_999_999_999L))334 assertEquals(240145L, packageVersionCodeForSdk(27, 240145, 9_999_999_999L))335 assertEquals(9_999_999_999L, packageVersionCodeForSdk(28, 240145, 9_999_999_999L))336 }337338 @Test339 fun `package signing flags helper selects API boundary`() {340 assertEquals(1, packageSigningInfoFlagsForSdk(26, 1, 2))341 assertEquals(1, packageSigningInfoFlagsForSdk(27, 1, 2))342 assertEquals(2, packageSigningInfoFlagsForSdk(28, 1, 2))343 }344345 346347 @Test348 fun `signing lineage accepts unchanged signer`() {349 assertTrue(350 isSigningLineageCompatible(351 archiveCurrent = setOf("current"),352 archiveHistory = setOf("current"),353 installedCurrent = setOf("current"),354 ),355 )356 }357358 @Test359 fun `signing lineage accepts a forward rotation proving the installed signer`() {360 assertTrue(361 isSigningLineageCompatible(362 archiveCurrent = setOf("rotated"),363 archiveHistory = setOf("installed", "rotated"),364 installedCurrent = setOf("installed"),365 ),366 )367 }368369 @Test370 fun `signing lineage rejects reverse rotation even when old signer is historical`() {371 assertFalse(372 isSigningLineageCompatible(373 archiveCurrent = setOf("old"),374 archiveHistory = setOf("old"),375 installedCurrent = setOf("rotated"),376 ),377 )378 }379380 @Test381 fun `signing lineage rejects a signer-set expansion`() {382 assertFalse(383 isSigningLineageCompatible(384 archiveCurrent = setOf("installed", "attacker"),385 archiveHistory = setOf("installed", "attacker"),386 installedCurrent = setOf("installed"),387 ),388 )389 }390391 @Test392 fun `release history parses newest-first with optional notes`() {393 394 val body = """395 [{"version":"2.10.21","notes":"Fixed things."},396 {"version":"2.10.20","notes":""},397 {"version":"2.10.19"}]398 """.trimIndent()399 val history = parseReleaseHistory(body)400 assertEquals(3, history.size)401 assertEquals("2.10.21", history[0].version)402 assertEquals("Fixed things.", history[0].notes)403 assertNull(history[1].notes)404 assertNull(history[2].notes)405 }406}