UpdateManifestTest.kt

15.1 KB · raw

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        // Kepr's current `releases/latest` shape (v2.9.19+): a `url` plus27        // `blake3` and `sha256` siblings instead of a single `checksum` string.28        // blake3 wins because it's the project's preferred algorithm.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        // `blake3` is present but not 64 lowercase hex, so it's unusable; the62        // legacy `checksum` is also missing — overall artifact has no checksum.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        // H-08: the sole-artifact fallback is gone. The canonical104        // android-arm64 key is the only one the updater will accept.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        // A Kepr build that only published android-x86 used to be accepted via120        // the sole-artifact fallback; now it is refused outright.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    /* ──────────────────────────  strict SemVer  ────────────────────────── */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        // Strict SemVer: 02 is not a valid integer literal, so 02.3.68 is not163        // a valid version string — the filesystem would also sort it oddly.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        // 99_999_999_999 would silently wrap in compareVersions' toIntOrNull;173        // strict validation refuses it outright.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    /* ───────────────────────────────  size cap  ─────────────────────────────── */189190    @Test191    fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {192        // The streaming branch checks Content-Length before reading bytes; the193        // simplest invariant is that the constant is positive and the well-194        // known 50 MiB. Locking the value here so any silent change has to195        // update this assertion.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    /** BLAKE3 known-answer vectors — the checksum gate is only worth anything if this is right. */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    /** APK identity gate — exact manifest SemVer plus a strictly newer Android versionCode. */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        // A signed APK with the right package name but a lower versionCode is289        // still the wrong APK — refuse so a stale or partial cache can't slip290        // through.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    /* ───────────────────── API-26/27 vs API-28+ branches ───────────────────── */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    /* ─────────────────────── signing-lineage direction ─────────────────────── */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        // Kepr's `releases/history` shape: newest first, notes optional/blank.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}