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 @Test(expected = ManifestFormatException::class)59 fun `no platforms is a hard failure`() {60 parseReleaseManifest("""{"version":"2.3.68"}""")61 }6263 @Test(expected = ManifestFormatException::class)64 fun `non-canonical platform key is rejected, no sole-artifact fallback`() {65 66 67 val body = """68 {"version":"2.3.68","platforms":{"android-x86":69 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}70 """.trimIndent()71 parseReleaseManifest(body)72 }7374 7576 @Test77 fun `parseReleaseManifest accepts a clean SemVer version`() {78 val parsed = parseReleaseManifest(manifest(""))79 assertEquals("2.3.68", parsed.version)80 }8182 @Test(expected = ManifestFormatException::class)83 fun `version with only two components is rejected`() {84 parseReleaseManifest(85 """{"version":"2.3","platforms":{"$ANDROID_PLATFORM_KEY":86 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",87 )88 }8990 @Test(expected = ManifestFormatException::class)91 fun `version with four components is rejected`() {92 parseReleaseManifest(93 """{"version":"2.3.68.0","platforms":{"$ANDROID_PLATFORM_KEY":94 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",95 )96 }9798 @Test(expected = ManifestFormatException::class)99 fun `version with path-traversal characters is rejected`() {100 parseReleaseManifest(101 """{"version":"../../../etc/passwd","platforms":{"$ANDROID_PLATFORM_KEY":102 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",103 )104 }105106 @Test(expected = ManifestFormatException::class)107 fun `version with a leading zero segment is rejected`() {108 109 110 parseReleaseManifest(111 """{"version":"02.3.68","platforms":{"$ANDROID_PLATFORM_KEY":112 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",113 )114 }115116 @Test(expected = ManifestFormatException::class)117 fun `version with an overflowing segment is rejected`() {118 119 120 parseReleaseManifest(121 """{"version":"99999999999.0.0","platforms":{"$ANDROID_PLATFORM_KEY":122 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",123 )124 }125126 @Test(expected = ManifestFormatException::class)127 fun `version with non-numeric components is rejected`() {128 parseReleaseManifest(129 """{"version":"2.3.NaN","platforms":{"$ANDROID_PLATFORM_KEY":130 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",131 )132 }133134 135136 @Test137 fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {138 139 140 141 142 assertEquals(50L * 1024 * 1024, MAX_APK_BYTES)143 assertTrue(MAX_APK_BYTES > 0)144 }145146 @Test147 fun `version comparison is numeric, not lexical`() {148 assertTrue(isNewerVersion("2.3.100", "2.3.67"))149 assertTrue(isNewerVersion("2.4.0", "2.3.99"))150 assertTrue(isNewerVersion("10.0.0", "9.9.9"))151 assertFalse(isNewerVersion("2.3.67", "2.3.67"))152 assertFalse(isNewerVersion("2.3.9", "2.3.10"))153 }154155 @Test156 fun `debug suffix compares as the release it was built from`() {157 assertEquals("2.3.67", normalizeVersion("2.3.67-debug"))158 assertEquals("2.3.67", normalizeVersion("v2.3.67"))159 assertFalse(isNewerVersion("2.3.67", "2.3.67-debug"))160 assertTrue(isNewerVersion("2.3.68", "2.3.67-debug"))161 }162163 @Test164 fun `only well-formed blake3 and sha256 checksums are accepted`() {165 assertEquals(166 ExpectedChecksum("blake3", "a".repeat(64)),167 parseChecksum("blake3:${"A".repeat(64)}"),168 )169 assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm)170 assertNull(parseChecksum("md5:${"a".repeat(64)}"))171 assertNull(parseChecksum("blake3:tooshort"))172 assertNull(parseChecksum("blake3:${"z".repeat(64)}"))173 assertNull(parseChecksum("nocolon"))174 }175176 177 @Test178 fun `blake3 matches the reference vectors`() {179 assertEquals(180 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",181 hashStream(ByteArray(0).inputStream(), "blake3"),182 )183 assertEquals(184 "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85",185 hashStream("abc".toByteArray().inputStream(), "blake3"),186 )187 }188189 @Test190 fun `sha256 matches the reference vector`() {191 assertEquals(192 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",193 hashStream("abc".toByteArray().inputStream(), "sha256"),194 )195 }196197 198 @Test199 fun `isExpectedApk accepts exact manifest version with a strictly newer code`() {200 assertTrue(201 isExpectedApk(202 packageName = "com.peachpasswords",203 versionName = "2.8.5",204 versionCode = 240146L,205 expectedPackage = "com.peachpasswords",206 expectedVersionName = "2.8.5",207 currentVersionCode = 240145L,208 ),209 )210 }211212 @Test213 fun `isExpectedApk rejects a foreign package`() {214 assertFalse(215 isExpectedApk(216 "com.evil.twin", "2.8.5", 999_999L,217 "com.peachpasswords", "2.8.5", 240145L,218 ),219 )220 }221222 @Test223 fun `isExpectedApk rejects separately installable debug package`() {224 assertFalse(225 isExpectedApk(226 "com.peachpasswords.debug", "2.8.5", 240200L,227 "com.peachpasswords", "2.8.5", 240145L,228 ),229 )230 }231232 @Test233 fun `isExpectedApk rejects a downgrade`() {234 235 236 237 assertFalse(238 isExpectedApk(239 "com.peachpasswords", "2.8.5", 240145L,240 "com.peachpasswords", "2.8.5", 240145L,241 ),242 )243 }244245 @Test246 fun `isExpectedApk rejects null and blank package names`() {247 assertFalse(248 isExpectedApk(null, "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),249 )250 assertFalse(251 isExpectedApk("", "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),252 )253 }254255 @Test256 fun `isExpectedApk rejects a semantic version mismatch even with a newer code`() {257 assertFalse(258 isExpectedApk(259 "com.peachpasswords", "2.8.6", 240200L,260 "com.peachpasswords", "2.8.5", 240145L,261 ),262 )263 }264265 @Test266 fun `isExpectedApk rejects an equal versionCode replay`() {267 assertFalse(268 isExpectedApk(269 "com.peachpasswords", "2.8.5", 240145L,270 "com.peachpasswords", "2.8.5", 240145L,271 ),272 )273 }274275 276277 @Test278 fun `package version code helper preserves legacy and modern fields`() {279 assertEquals(240145L, packageVersionCodeForSdk(26, 240145, 9_999_999_999L))280 assertEquals(240145L, packageVersionCodeForSdk(27, 240145, 9_999_999_999L))281 assertEquals(9_999_999_999L, packageVersionCodeForSdk(28, 240145, 9_999_999_999L))282 }283284 @Test285 fun `package signing flags helper selects API boundary`() {286 assertEquals(1, packageSigningInfoFlagsForSdk(26, 1, 2))287 assertEquals(1, packageSigningInfoFlagsForSdk(27, 1, 2))288 assertEquals(2, packageSigningInfoFlagsForSdk(28, 1, 2))289 }290291 292293 @Test294 fun `signing lineage accepts unchanged signer`() {295 assertTrue(296 isSigningLineageCompatible(297 archiveCurrent = setOf("current"),298 archiveHistory = setOf("current"),299 installedCurrent = setOf("current"),300 ),301 )302 }303304 @Test305 fun `signing lineage accepts a forward rotation proving the installed signer`() {306 assertTrue(307 isSigningLineageCompatible(308 archiveCurrent = setOf("rotated"),309 archiveHistory = setOf("installed", "rotated"),310 installedCurrent = setOf("installed"),311 ),312 )313 }314315 @Test316 fun `signing lineage rejects reverse rotation even when old signer is historical`() {317 assertFalse(318 isSigningLineageCompatible(319 archiveCurrent = setOf("old"),320 archiveHistory = setOf("old"),321 installedCurrent = setOf("rotated"),322 ),323 )324 }325326 @Test327 fun `signing lineage rejects a signer-set expansion`() {328 assertFalse(329 isSigningLineageCompatible(330 archiveCurrent = setOf("installed", "attacker"),331 archiveHistory = setOf("installed", "attacker"),332 installedCurrent = setOf("installed"),333 ),334 )335 }336}