UpdateManifestTest.kt

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