package com.peachpasswords.updates import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.assertFalse import org.junit.Test class UpdateManifestTest { private fun manifest(notesField: String) = """ { "version": "2.3.68", $notesField "platforms": { "$ANDROID_PLATFORM_KEY": { "url": "https://kepr.uk/api/peach-android/releases/2.3.68/$ANDROID_PLATFORM_KEY", "checksum": "blake3:${"a".repeat(64)}" } } } """.trimIndent() @Test fun `modern artifact with separate blake3 and sha256 fields is accepted`() { // Kepr's current `releases/latest` shape (v2.9.19+): a `url` plus // `blake3` and `sha256` siblings instead of a single `checksum` string. // blake3 wins because it's the project's preferred algorithm. val body = """ {"version":"2.9.19","platforms":{"android-arm64":{ "url":"https://x/y", "blake3":"${"a".repeat(64)}", "sha256":"${"b".repeat(64)}"}}} """.trimIndent() val parsed = parseReleaseManifest(body) assertEquals("https://x/y", parsed.url) assertEquals(ExpectedChecksum("blake3", "a".repeat(64)), parseChecksum(parsed.checksum)) } @Test fun `artifact with only sha256 falls back to sha256 checksum`() { val body = """ {"version":"2.9.19","platforms":{"android-arm64":{ "url":"https://x/y","sha256":"${"b".repeat(64)}"}}} """.trimIndent() val parsed = parseReleaseManifest(body) assertEquals(ExpectedChecksum("sha256", "b".repeat(64)), parseChecksum(parsed.checksum)) } @Test fun `artifact with no checksum in any shape is rejected`() { val body = """ {"version":"2.9.19","platforms":{"android-arm64":{"url":"https://x/y"}}} """.trimIndent() val ex = runCatching { parseReleaseManifest(body) }.exceptionOrNull() assertTrue(ex is ManifestFormatException) } @Test fun `modern artifact with malformed blake3 hex is rejected`() { // `blake3` is present but not 64 lowercase hex, so it's unusable; the // legacy `checksum` is also missing — overall artifact has no checksum. val body = """ {"version":"2.9.19","platforms":{"android-arm64":{ "url":"https://x/y","blake3":"not-hex"}}} """.trimIndent() val ex = runCatching { parseReleaseManifest(body) }.exceptionOrNull() assertTrue(ex is ManifestFormatException) } @Test fun `canonical notes key wins`() { val parsed = parseReleaseManifest(manifest(""""notes": "fixed the thing", "release_notes": "old",""")) assertEquals("fixed the thing", parsed.notes) assertEquals("2.3.68", parsed.version) } @Test fun `legacy release_notes key still accepted`() { assertEquals("legacy", parseReleaseManifest(manifest(""""release_notes": "legacy",""")).notes) } @Test fun `omitted notes are absent, not the string null`() { assertNull(parseReleaseManifest(manifest("")).notes) } @Test fun `json null notes never render as the text null`() { assertNull(parseReleaseManifest(manifest(""""notes": null,""")).notes) assertNull(parseReleaseManifest(manifest(""""notes": " ",""")).notes) } @Test(expected = ManifestFormatException::class) fun `non HTTPS artifact URL is rejected`() { parseReleaseManifest( """{"version":"2.3.68","platforms":{"android-arm64":{"url":"http://x/y","checksum":"sha256:${"b".repeat(64)}"}}}""", ) } @Test fun `canonical platform artifact is accepted`() { // H-08: the sole-artifact fallback is gone. The canonical // android-arm64 key is the only one the updater will accept. val body = """ {"version":"2.3.68","platforms":{"android-arm64": {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}} """.trimIndent() assertEquals("https://x/y", parseReleaseManifest(body).url) } @Test(expected = ManifestFormatException::class) fun `no platforms is a hard failure`() { parseReleaseManifest("""{"version":"2.3.68"}""") } @Test(expected = ManifestFormatException::class) fun `non-canonical platform key is rejected, no sole-artifact fallback`() { // A Kepr build that only published android-x86 used to be accepted via // the sole-artifact fallback; now it is refused outright. val body = """ {"version":"2.3.68","platforms":{"android-x86": {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}} """.trimIndent() parseReleaseManifest(body) } /* ────────────────────────── strict SemVer ────────────────────────── */ @Test fun `parseReleaseManifest accepts a clean SemVer version`() { val parsed = parseReleaseManifest(manifest("")) assertEquals("2.3.68", parsed.version) } @Test(expected = ManifestFormatException::class) fun `version with only two components is rejected`() { parseReleaseManifest( """{"version":"2.3","platforms":{"$ANDROID_PLATFORM_KEY": {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""", ) } @Test(expected = ManifestFormatException::class) fun `version with four components is rejected`() { parseReleaseManifest( """{"version":"2.3.68.0","platforms":{"$ANDROID_PLATFORM_KEY": {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""", ) } @Test(expected = ManifestFormatException::class) fun `version with path-traversal characters is rejected`() { parseReleaseManifest( """{"version":"../../../etc/passwd","platforms":{"$ANDROID_PLATFORM_KEY": {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""", ) } @Test(expected = ManifestFormatException::class) fun `version with a leading zero segment is rejected`() { // Strict SemVer: 02 is not a valid integer literal, so 02.3.68 is not // a valid version string — the filesystem would also sort it oddly. parseReleaseManifest( """{"version":"02.3.68","platforms":{"$ANDROID_PLATFORM_KEY": {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""", ) } @Test(expected = ManifestFormatException::class) fun `version with an overflowing segment is rejected`() { // 99_999_999_999 would silently wrap in compareVersions' toIntOrNull; // strict validation refuses it outright. parseReleaseManifest( """{"version":"99999999999.0.0","platforms":{"$ANDROID_PLATFORM_KEY": {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""", ) } @Test(expected = ManifestFormatException::class) fun `version with non-numeric components is rejected`() { parseReleaseManifest( """{"version":"2.3.NaN","platforms":{"$ANDROID_PLATFORM_KEY": {"url":"u","checksum":"blake3:${"a".repeat(64)}"}}}""", ) } /* ─────────────────────────────── size cap ─────────────────────────────── */ @Test fun `MAX_APK_BYTES is a hard ceiling an advertised Content-Length can be compared against`() { // The streaming branch checks Content-Length before reading bytes; the // simplest invariant is that the constant is positive and the well- // known 50 MiB. Locking the value here so any silent change has to // update this assertion. assertEquals(50L * 1024 * 1024, MAX_APK_BYTES) assertTrue(MAX_APK_BYTES > 0) } @Test fun `version comparison is numeric, not lexical`() { assertTrue(isNewerVersion("2.3.100", "2.3.67")) assertTrue(isNewerVersion("2.4.0", "2.3.99")) assertTrue(isNewerVersion("10.0.0", "9.9.9")) assertFalse(isNewerVersion("2.3.67", "2.3.67")) assertFalse(isNewerVersion("2.3.9", "2.3.10")) } @Test fun `debug suffix compares as the release it was built from`() { assertEquals("2.3.67", normalizeVersion("2.3.67-debug")) assertEquals("2.3.67", normalizeVersion("v2.3.67")) assertFalse(isNewerVersion("2.3.67", "2.3.67-debug")) assertTrue(isNewerVersion("2.3.68", "2.3.67-debug")) } @Test fun `only well-formed blake3 and sha256 checksums are accepted`() { assertEquals( ExpectedChecksum("blake3", "a".repeat(64)), parseChecksum("blake3:${"A".repeat(64)}"), ) assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm) assertNull(parseChecksum("md5:${"a".repeat(64)}")) assertNull(parseChecksum("blake3:tooshort")) assertNull(parseChecksum("blake3:${"z".repeat(64)}")) assertNull(parseChecksum("nocolon")) } /** BLAKE3 known-answer vectors — the checksum gate is only worth anything if this is right. */ @Test fun `blake3 matches the reference vectors`() { assertEquals( "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262", hashStream(ByteArray(0).inputStream(), "blake3"), ) assertEquals( "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85", hashStream("abc".toByteArray().inputStream(), "blake3"), ) } @Test fun `sha256 matches the reference vector`() { assertEquals( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hashStream("abc".toByteArray().inputStream(), "sha256"), ) } /** APK identity gate — exact manifest SemVer plus a strictly newer Android versionCode. */ @Test fun `isExpectedApk accepts exact manifest version with a strictly newer code`() { assertTrue( isExpectedApk( packageName = "com.peachpasswords", versionName = "2.8.5", versionCode = 240146L, expectedPackage = "com.peachpasswords", expectedVersionName = "2.8.5", currentVersionCode = 240145L, ), ) } @Test fun `isExpectedApk rejects a foreign package`() { assertFalse( isExpectedApk( "com.evil.twin", "2.8.5", 999_999L, "com.peachpasswords", "2.8.5", 240145L, ), ) } @Test fun `isExpectedApk rejects separately installable debug package`() { assertFalse( isExpectedApk( "com.peachpasswords.debug", "2.8.5", 240200L, "com.peachpasswords", "2.8.5", 240145L, ), ) } @Test fun `isExpectedApk rejects a downgrade`() { // A signed APK with the right package name but a lower versionCode is // still the wrong APK — refuse so a stale or partial cache can't slip // through. assertFalse( isExpectedApk( "com.peachpasswords", "2.8.5", 240145L, "com.peachpasswords", "2.8.5", 240145L, ), ) } @Test fun `isExpectedApk rejects null and blank package names`() { assertFalse( isExpectedApk(null, "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L), ) assertFalse( isExpectedApk("", "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L), ) } @Test fun `isExpectedApk rejects a semantic version mismatch even with a newer code`() { assertFalse( isExpectedApk( "com.peachpasswords", "2.8.6", 240200L, "com.peachpasswords", "2.8.5", 240145L, ), ) } @Test fun `isExpectedApk rejects an equal versionCode replay`() { assertFalse( isExpectedApk( "com.peachpasswords", "2.8.5", 240145L, "com.peachpasswords", "2.8.5", 240145L, ), ) } /* ───────────────────── API-26/27 vs API-28+ branches ───────────────────── */ @Test fun `package version code helper preserves legacy and modern fields`() { assertEquals(240145L, packageVersionCodeForSdk(26, 240145, 9_999_999_999L)) assertEquals(240145L, packageVersionCodeForSdk(27, 240145, 9_999_999_999L)) assertEquals(9_999_999_999L, packageVersionCodeForSdk(28, 240145, 9_999_999_999L)) } @Test fun `package signing flags helper selects API boundary`() { assertEquals(1, packageSigningInfoFlagsForSdk(26, 1, 2)) assertEquals(1, packageSigningInfoFlagsForSdk(27, 1, 2)) assertEquals(2, packageSigningInfoFlagsForSdk(28, 1, 2)) } /* ─────────────────────── signing-lineage direction ─────────────────────── */ @Test fun `signing lineage accepts unchanged signer`() { assertTrue( isSigningLineageCompatible( archiveCurrent = setOf("current"), archiveHistory = setOf("current"), installedCurrent = setOf("current"), ), ) } @Test fun `signing lineage accepts a forward rotation proving the installed signer`() { assertTrue( isSigningLineageCompatible( archiveCurrent = setOf("rotated"), archiveHistory = setOf("installed", "rotated"), installedCurrent = setOf("installed"), ), ) } @Test fun `signing lineage rejects reverse rotation even when old signer is historical`() { assertFalse( isSigningLineageCompatible( archiveCurrent = setOf("old"), archiveHistory = setOf("old"), installedCurrent = setOf("rotated"), ), ) } @Test fun `signing lineage rejects a signer-set expansion`() { assertFalse( isSigningLineageCompatible( archiveCurrent = setOf("installed", "attacker"), archiveHistory = setOf("installed", "attacker"), installedCurrent = setOf("installed"), ), ) } @Test fun `release history parses newest-first with optional notes`() { // Kepr's `releases/history` shape: newest first, notes optional/blank. val body = """ [{"version":"2.10.21","notes":"Fixed things."}, {"version":"2.10.20","notes":""}, {"version":"2.10.19"}] """.trimIndent() val history = parseReleaseHistory(body) assertEquals(3, history.size) assertEquals("2.10.21", history[0].version) assertEquals("Fixed things.", history[0].notes) assertNull(history[1].notes) assertNull(history[2].notes) } }