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 `missing platform falls back to the sole artifact`() {49 50 51 52 val body = """53 {"version":"2.3.68","platforms":{"android-arm64":54 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}55 """.trimIndent()56 assertEquals("https://x/y", parseReleaseManifest(body).url)57 }5859 @Test(expected = ManifestFormatException::class)60 fun `no platforms is a hard failure`() {61 parseReleaseManifest("""{"version":"2.3.68"}""")62 }6364 @Test(expected = ManifestFormatException::class)65 fun `non-canonical platform key is rejected, no sole-artifact fallback`() {66 67 68 val body = """69 {"version":"2.3.68","platforms":{"android-x86":70 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}71 """.trimIndent()72 parseReleaseManifest(body)73 }7475 7677 @Test78 fun `parseReleaseManifest accepts a clean SemVer version`() {79 val parsed = parseReleaseManifest(manifest(""))80 assertEquals("2.3.68", parsed.version)81 }8283 @Test(expected = ManifestFormatException::class)84 fun `version with only two components is rejected`() {85 parseReleaseManifest(86 """{"version":"2.3","platforms":{"$ANDROID_PLATFORM_KEY":87 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",88 )89 }9091 @Test(expected = ManifestFormatException::class)92 fun `version with four components is rejected`() {93 parseReleaseManifest(94 """{"version":"2.3.68.0","platforms":{"$ANDROID_PLATFORM_KEY":95 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",96 )97 }9899 @Test(expected = ManifestFormatException::class)100 fun `version with path-traversal characters is rejected`() {101 parseReleaseManifest(102 """{"version":"../../../etc/passwd","platforms":{"$ANDROID_PLATFORM_KEY":103 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",104 )105 }106107 @Test(expected = ManifestFormatException::class)108 fun `version with a leading zero segment is rejected`() {109 110 111 parseReleaseManifest(112 """{"version":"02.3.68","platforms":{"$ANDROID_PLATFORM_KEY":113 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",114 )115 }116117 @Test(expected = ManifestFormatException::class)118 fun `version with an overflowing segment is rejected`() {119 120 121 parseReleaseManifest(122 """{"version":"99999999999.0.0","platforms":{"$ANDROID_PLATFORM_KEY":123 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",124 )125 }126127 @Test(expected = ManifestFormatException::class)128 fun `version with non-numeric components is rejected`() {129 parseReleaseManifest(130 """{"version":"2.3.NaN","platforms":{"$ANDROID_PLATFORM_KEY":131 {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",132 )133 }134135 136137 @Test138 fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {139 140 141 142 143 assertEquals(50L * 1024 * 1024, MAX_APK_BYTES)144 assertTrue(MAX_APK_BYTES > 0)145 }146147 @Test148 fun `version comparison is numeric, not lexical`() {149 assertTrue(isNewerVersion("2.3.100", "2.3.67"))150 assertTrue(isNewerVersion("2.4.0", "2.3.99"))151 assertTrue(isNewerVersion("10.0.0", "9.9.9"))152 assertFalse(isNewerVersion("2.3.67", "2.3.67"))153 assertFalse(isNewerVersion("2.3.9", "2.3.10"))154 }155156 @Test157 fun `debug suffix compares as the release it was built from`() {158 assertEquals("2.3.67", normalizeVersion("2.3.67-debug"))159 assertEquals("2.3.67", normalizeVersion("v2.3.67"))160 assertFalse(isNewerVersion("2.3.67", "2.3.67-debug"))161 assertTrue(isNewerVersion("2.3.68", "2.3.67-debug"))162 }163164 @Test165 fun `only well-formed blake3 and sha256 checksums are accepted`() {166 assertEquals(167 ExpectedChecksum("blake3", "a".repeat(64)),168 parseChecksum("blake3:${"A".repeat(64)}"),169 )170 assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm)171 assertNull(parseChecksum("md5:${"a".repeat(64)}"))172 assertNull(parseChecksum("blake3:tooshort"))173 assertNull(parseChecksum("blake3:${"z".repeat(64)}"))174 assertNull(parseChecksum("nocolon"))175 }176177 178 @Test179 fun `blake3 matches the reference vectors`() {180 assertEquals(181 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",182 hashStream(ByteArray(0).inputStream(), "blake3"),183 )184 assertEquals(185 "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85",186 hashStream("abc".toByteArray().inputStream(), "blake3"),187 )188 }189190 @Test191 fun `sha256 matches the reference vector`() {192 assertEquals(193 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",194 hashStream("abc".toByteArray().inputStream(), "sha256"),195 )196 }197198 199 @Test200 fun `isExpectedApk accepts production and debug packages at or above the running version`() {201 assertTrue(isExpectedApk("com.peachpasswords", 240146L, "com.peachpasswords", 240145))202 assertTrue(isExpectedApk("com.peachpasswords", 240145L, "com.peachpasswords", 240145))203 assertTrue(isExpectedApk("com.peachpasswords.debug", 240200L, "com.peachpasswords", 240145))204 }205206 @Test207 fun `isExpectedApk rejects a foreign package`() {208 assertFalse(isExpectedApk("com.evil.twin", 999_999L, "com.peachpasswords", 240145))209 }210211 @Test212 fun `isExpectedApk rejects a downgrade`() {213 214 215 216 assertFalse(isExpectedApk("com.peachpasswords", 1L, "com.peachpasswords", 240145))217 }218219 @Test220 fun `isExpectedApk rejects null and blank package names`() {221 assertFalse(isExpectedApk(null, 999L, "com.peachpasswords", 240145))222 assertFalse(isExpectedApk("", 999L, "com.peachpasswords", 240145))223 }224}