UpdateManifestTest.kt

14.2 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    @Test95    fun `canonical platform artifact is accepted`() {96        // H-08: the sole-artifact fallback is gone. The canonical97        // android-arm64 key is the only one the updater will accept.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        // A Kepr build that only published android-x86 used to be accepted via113        // the sole-artifact fallback; now it is refused outright.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    /* ──────────────────────────  strict SemVer  ────────────────────────── */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        // Strict SemVer: 02 is not a valid integer literal, so 02.3.68 is not156        // a valid version string — the filesystem would also sort it oddly.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        // 99_999_999_999 would silently wrap in compareVersions' toIntOrNull;166        // strict validation refuses it outright.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    /* ───────────────────────────────  size cap  ─────────────────────────────── */182183    @Test184    fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {185        // The streaming branch checks Content-Length before reading bytes; the186        // simplest invariant is that the constant is positive and the well-187        // known 50 MiB. Locking the value here so any silent change has to188        // update this assertion.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    /** BLAKE3 known-answer vectors — the checksum gate is only worth anything if this is right. */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    /** APK identity gate — exact manifest SemVer plus a strictly newer Android versionCode. */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        // A signed APK with the right package name but a lower versionCode is282        // still the wrong APK — refuse so a stale or partial cache can't slip283        // through.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    /* ───────────────────── API-26/27 vs API-28+ branches ───────────────────── */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    /* ─────────────────────── signing-lineage direction ─────────────────────── */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    }383}