UpdateManifestTest.kt

8.9 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 `missing platform falls back to the sole artifact`() {49        // H-08: the sole-artifact fallback is gone. The canonical50        // android-arm64 key is the only one the updater will accept — but the51        // test fixture is that key, so the canonical path still passes.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        // A Kepr build that only published android-x86 used to be accepted via67        // the sole-artifact fallback; now it is refused outright.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    /* ──────────────────────────  strict SemVer  ────────────────────────── */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        // Strict SemVer: 02 is not a valid integer literal, so 02.3.68 is not110        // a valid version string — the filesystem would also sort it oddly.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        // 99_999_999_999 would silently wrap in compareVersions' toIntOrNull;120        // strict validation refuses it outright.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    /* ───────────────────────────────  size cap  ─────────────────────────────── */136137    @Test138    fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() {139        // The streaming branch checks Content-Length before reading bytes; the140        // simplest invariant is that the constant is positive and the well-141        // known 50 MiB. Locking the value here so any silent change has to142        // update this assertion.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    /** BLAKE3 known-answer vectors — the checksum gate is only worth anything if this is right. */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    /** APK identity gate — must accept only this app's package at this app's version-or-newer. */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        // A signed APK with the right package name but a lower versionCode is214        // still the wrong APK — refuse so a stale or partial cache can't slip215        // through.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}