UpdateManifestTest.kt

12.8 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    @Test59    fun `platform selection follows the device ABI`() {60        assertEquals(ANDROID_PLATFORM_KEY, androidPlatformKey(arrayOf("arm64-v8a", "armeabi-v7a")))61        assertEquals(ANDROID_X86_PLATFORM_KEY, androidPlatformKey(arrayOf("x86_64", "x86")))62        assertNull(androidPlatformKey(arrayOf("armeabi-v7a")))63    }6465    @Test66    fun `x86 device selects the x86 artifact`() {67        val body = """68            {"version":"2.3.68","platforms":{"android-x86":69              {"url":"https://x/x86.apk","checksum":"sha256:${"b".repeat(64)}"}}}70        """.trimIndent()71        assertEquals(72            "https://x/x86.apk",73            parseReleaseManifest(body, androidPlatformKey(arrayOf("x86_64"))!!).url,74        )75    }7677    @Test(expected = ManifestFormatException::class)78    fun `no platforms is a hard failure`() {79        parseReleaseManifest("""{"version":"2.3.68"}""")80    }8182    @Test(expected = ManifestFormatException::class)83    fun `non-canonical platform key is rejected, no sole-artifact fallback`() {84        // An ARM64 device must not silently accept the x86 artifact.85        val body = """86            {"version":"2.3.68","platforms":{"android-x86":87              {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}88        """.trimIndent()89        parseReleaseManifest(body)90    }9192    /* ──────────────────────────  strict SemVer  ────────────────────────── */9394    @Test95    fun `parseReleaseManifest accepts a clean SemVer version`() {96        val parsed = parseReleaseManifest(manifest(""))97        assertEquals("2.3.68", parsed.version)98    }99100    @Test(expected = ManifestFormatException::class)101    fun `version with only two components is rejected`() {102        parseReleaseManifest(103            """{"version":"2.3","platforms":{"$ANDROID_PLATFORM_KEY":104                {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",105        )106    }107108    @Test(expected = ManifestFormatException::class)109    fun `version with four components is rejected`() {110        parseReleaseManifest(111            """{"version":"2.3.68.0","platforms":{"$ANDROID_PLATFORM_KEY":112                {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",113        )114    }115116    @Test(expected = ManifestFormatException::class)117    fun `version with path-traversal characters is rejected`() {118        parseReleaseManifest(119            """{"version":"../../../etc/passwd","platforms":{"$ANDROID_PLATFORM_KEY":120                {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",121        )122    }123124    @Test(expected = ManifestFormatException::class)125    fun `version with a leading zero segment is rejected`() {126        // Strict SemVer: 02 is not a valid integer literal, so 02.3.68 is not127        // a valid version string — the filesystem would also sort it oddly.128        parseReleaseManifest(129            """{"version":"02.3.68","platforms":{"$ANDROID_PLATFORM_KEY":130                {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",131        )132    }133134    @Test(expected = ManifestFormatException::class)135    fun `version with an overflowing segment is rejected`() {136        // 99_999_999_999 would silently wrap in compareVersions' toIntOrNull;137        // strict validation refuses it outright.138        parseReleaseManifest(139            """{"version":"99999999999.0.0","platforms":{"$ANDROID_PLATFORM_KEY":140                {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",141        )142    }143144    @Test(expected = ManifestFormatException::class)145    fun `version with non-numeric components is rejected`() {146        parseReleaseManifest(147            """{"version":"2.3.NaN","platforms":{"$ANDROID_PLATFORM_KEY":148                {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""",149        )150    }151152    /* ───────────────────────────────  size cap  ─────────────────────────────── */153154    @Test155    fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {156        // The streaming branch checks Content-Length before reading bytes; the157        // simplest invariant is that the constant is positive and the well-158        // known 50 MiB. Locking the value here so any silent change has to159        // update this assertion.160        assertEquals(50L * 1024 * 1024, MAX_APK_BYTES)161        assertTrue(MAX_APK_BYTES > 0)162    }163164    @Test165    fun `version comparison is numeric, not lexical`() {166        assertTrue(isNewerVersion("2.3.100", "2.3.67"))167        assertTrue(isNewerVersion("2.4.0", "2.3.99"))168        assertTrue(isNewerVersion("10.0.0", "9.9.9"))169        assertFalse(isNewerVersion("2.3.67", "2.3.67"))170        assertFalse(isNewerVersion("2.3.9", "2.3.10"))171    }172173    @Test174    fun `debug suffix compares as the release it was built from`() {175        assertEquals("2.3.67", normalizeVersion("2.3.67-debug"))176        assertEquals("2.3.67", normalizeVersion("v2.3.67"))177        assertFalse(isNewerVersion("2.3.67", "2.3.67-debug"))178        assertTrue(isNewerVersion("2.3.68", "2.3.67-debug"))179    }180181    @Test182    fun `only well-formed blake3 and sha256 checksums are accepted`() {183        assertEquals(184            ExpectedChecksum("blake3", "a".repeat(64)),185            parseChecksum("blake3:${"A".repeat(64)}"),186        )187        assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm)188        assertNull(parseChecksum("md5:${"a".repeat(64)}"))189        assertNull(parseChecksum("blake3:tooshort"))190        assertNull(parseChecksum("blake3:${"z".repeat(64)}"))191        assertNull(parseChecksum("nocolon"))192    }193194    /** BLAKE3 known-answer vectors — the checksum gate is only worth anything if this is right. */195    @Test196    fun `blake3 matches the reference vectors`() {197        assertEquals(198            "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",199            hashStream(ByteArray(0).inputStream(), "blake3"),200        )201        assertEquals(202            "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85",203            hashStream("abc".toByteArray().inputStream(), "blake3"),204        )205    }206207    @Test208    fun `sha256 matches the reference vector`() {209        assertEquals(210            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",211            hashStream("abc".toByteArray().inputStream(), "sha256"),212        )213    }214215    /** APK identity gate — exact manifest SemVer plus a strictly newer Android versionCode. */216    @Test217    fun `isExpectedApk accepts exact manifest version with a strictly newer code`() {218        assertTrue(219            isExpectedApk(220                packageName = "com.peachpasswords",221                versionName = "2.8.5",222                versionCode = 240146L,223                expectedPackage = "com.peachpasswords",224                expectedVersionName = "2.8.5",225                currentVersionCode = 240145L,226            ),227        )228    }229230    @Test231    fun `isExpectedApk rejects a foreign package`() {232        assertFalse(233            isExpectedApk(234                "com.evil.twin", "2.8.5", 999_999L,235                "com.peachpasswords", "2.8.5", 240145L,236            ),237        )238    }239240    @Test241    fun `isExpectedApk rejects separately installable debug package`() {242        assertFalse(243            isExpectedApk(244                "com.peachpasswords.debug", "2.8.5", 240200L,245                "com.peachpasswords", "2.8.5", 240145L,246            ),247        )248    }249250    @Test251    fun `isExpectedApk rejects a downgrade`() {252        // A signed APK with the right package name but a lower versionCode is253        // still the wrong APK — refuse so a stale or partial cache can't slip254        // through.255        assertFalse(256            isExpectedApk(257                "com.peachpasswords", "2.8.5", 240145L,258                "com.peachpasswords", "2.8.5", 240145L,259            ),260        )261    }262263    @Test264    fun `isExpectedApk rejects null and blank package names`() {265        assertFalse(266            isExpectedApk(null, "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),267        )268        assertFalse(269            isExpectedApk("", "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),270        )271    }272273    @Test274    fun `isExpectedApk rejects a semantic version mismatch even with a newer code`() {275        assertFalse(276            isExpectedApk(277                "com.peachpasswords", "2.8.6", 240200L,278                "com.peachpasswords", "2.8.5", 240145L,279            ),280        )281    }282283    @Test284    fun `isExpectedApk rejects an equal versionCode replay`() {285        assertFalse(286            isExpectedApk(287                "com.peachpasswords", "2.8.5", 240145L,288                "com.peachpasswords", "2.8.5", 240145L,289            ),290        )291    }292293    /* ───────────────────── API-26/27 vs API-28+ branches ───────────────────── */294295    @Test296    fun `package version code helper preserves legacy and modern fields`() {297        assertEquals(240145L, packageVersionCodeForSdk(26, 240145, 9_999_999_999L))298        assertEquals(240145L, packageVersionCodeForSdk(27, 240145, 9_999_999_999L))299        assertEquals(9_999_999_999L, packageVersionCodeForSdk(28, 240145, 9_999_999_999L))300    }301302    @Test303    fun `package signing flags helper selects API boundary`() {304        assertEquals(1, packageSigningInfoFlagsForSdk(26, 1, 2))305        assertEquals(1, packageSigningInfoFlagsForSdk(27, 1, 2))306        assertEquals(2, packageSigningInfoFlagsForSdk(28, 1, 2))307    }308309    /* ─────────────────────── signing-lineage direction ─────────────────────── */310311    @Test312    fun `signing lineage accepts unchanged signer`() {313        assertTrue(314            isSigningLineageCompatible(315                archiveCurrent = setOf("current"),316                archiveHistory = setOf("current"),317                installedCurrent = setOf("current"),318            ),319        )320    }321322    @Test323    fun `signing lineage accepts a forward rotation proving the installed signer`() {324        assertTrue(325            isSigningLineageCompatible(326                archiveCurrent = setOf("rotated"),327                archiveHistory = setOf("installed", "rotated"),328                installedCurrent = setOf("installed"),329            ),330        )331    }332333    @Test334    fun `signing lineage rejects reverse rotation even when old signer is historical`() {335        assertFalse(336            isSigningLineageCompatible(337                archiveCurrent = setOf("old"),338                archiveHistory = setOf("old"),339                installedCurrent = setOf("rotated"),340            ),341        )342    }343344    @Test345    fun `signing lineage rejects a signer-set expansion`() {346        assertFalse(347            isSigningLineageCompatible(348                archiveCurrent = setOf("installed", "attacker"),349                archiveHistory = setOf("installed", "attacker"),350                installedCurrent = setOf("installed"),351            ),352        )353    }354}