From 5232ee561ebb56766e7a0b2e3af55150f5163b23 Mon Sep 17 00:00:00 2001 From: Hidden-Node Date: Thu, 23 Jul 2026 02:03:10 +0330 Subject: [PATCH] test: add Kotlin JVM instrumentation scaffolding and characterization tests --- .github/workflows/android-ci.yml | 4 + android/app/build.gradle.kts | 9 ++ .../com/masterdns/vpn/RunnerResolvesTest.kt | 16 +++ .../settings/ResolverBalancingStrategies.kt | 8 ++ .../vpn/ui/settings/SettingsViewModel.kt | 7 -- .../com/masterdns/vpn/util/ConfigGenerator.kt | 2 +- .../masterdns/vpn/util/ResolverAnalyzer.kt | 8 +- .../com/masterdns/vpn/ConfigGeneratorTest.kt | 36 +++++++ .../com/masterdns/vpn/ResolverAnalyzerTest.kt | 97 +++++++++++++++++++ .../ResolverBalancingStrategiesTest.kt | 12 +++ 10 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 android/app/src/androidTest/java/com/masterdns/vpn/RunnerResolvesTest.kt create mode 100644 android/app/src/main/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategies.kt create mode 100644 android/app/src/test/java/com/masterdns/vpn/ConfigGeneratorTest.kt create mode 100644 android/app/src/test/java/com/masterdns/vpn/ResolverAnalyzerTest.kt create mode 100644 android/app/src/test/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategiesTest.kt diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 2b28431..55612a8 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -44,6 +44,10 @@ jobs: working-directory: android run: chmod +x gradlew && ./gradlew :app:assembleDebug --stacktrace + - name: Run JVM unit tests + working-directory: android + run: chmod +x gradlew && ./gradlew :app:testDebugUnitTest --stacktrace + - name: Upload APK artifact uses: actions/upload-artifact@v4 with: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d82b097..653727d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -148,4 +148,13 @@ dependencies { // Debug debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") + + // JVM unit tests + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + + // Instrumented tests (configure the runner so connectedAndroidTest doesn't NPE) + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") } diff --git a/android/app/src/androidTest/java/com/masterdns/vpn/RunnerResolvesTest.kt b/android/app/src/androidTest/java/com/masterdns/vpn/RunnerResolvesTest.kt new file mode 100644 index 0000000..61ac594 --- /dev/null +++ b/android/app/src/androidTest/java/com/masterdns/vpn/RunnerResolvesTest.kt @@ -0,0 +1,16 @@ +package com.masterdns.vpn + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RunnerResolvesTest { + @Test + fun runnerClassResolves() { + // The mere fact this test compiles and the runner loads proves + // the androidTestImplementation dependencies are on the classpath. + assertEquals(4, 2 + 2) + } +} diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategies.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategies.kt new file mode 100644 index 0000000..71a0233 --- /dev/null +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategies.kt @@ -0,0 +1,8 @@ +package com.masterdns.vpn.ui.settings + +internal fun normalizeResolverBalancingStrategy(value: String?, fallback: Int): Int { + val parsed = value?.trim()?.toIntOrNull() + if (parsed != null && parsed in 1..8) return parsed + if (parsed == 0) return 2 + return if (fallback in 1..8) fallback else 2 +} diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt index a7b8a7a..aa94fd7 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt @@ -99,13 +99,6 @@ class SettingsViewModel @Inject constructor( } } - private fun normalizeResolverBalancingStrategy(value: String?, fallback: Int): Int { - val parsed = value?.trim()?.toIntOrNull() - if (parsed != null && parsed in 1..8) return parsed - if (parsed == 0) return 2 - return if (fallback in 1..8) fallback else 2 - } - private fun buildUpdatedProfile(profile: ProfileEntity, values: Map): ProfileEntity { val mergedAdvanced = parseAdvanced(profile.advancedJson).toMutableMap() values.forEach { (key, value) -> diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt index 12b36d1..ae56a6a 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt @@ -216,7 +216,7 @@ object ConfigGenerator { } } - private fun escapeToml(s: String): String { + internal fun escapeToml(s: String): String { return s.replace("\\", "\\\\").replace("\"", "\\\"") } } diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt index 6a01ec3..d5c02f6 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt @@ -163,13 +163,13 @@ object ResolverAnalyzer { return runCatching { gson.fromJson(json, ResolverImportStats::class.java) }.getOrNull() } - private data class ResolverEntry( + internal data class ResolverEntry( val host: String, val port: Int, val hasExplicitPort: Boolean ) - private fun parseEntry(line: String): ResolverEntry? { + internal fun parseEntry(line: String): ResolverEntry? { val text = line.trim() if (text.isEmpty()) return null if (text.startsWith("[")) { @@ -196,7 +196,7 @@ object ResolverAnalyzer { return ResolverEntry(text, DEFAULT_PORT, false) } - private fun parseIp(host: String): String? { + internal fun parseIp(host: String): String? { val text = host.trim() val numericCandidate = when { "." in text && ":" !in text -> text.matches(Regex("\\d{1,3}(\\.\\d{1,3}){3}")) @@ -207,7 +207,7 @@ object ResolverAnalyzer { return runCatching { InetAddress.getByName(text).hostAddress }.getOrNull() } - private fun expandCidr(value: String): List? { + internal fun expandCidr(value: String): List? { val parts = value.split("/") if (parts.size != 2) return null val normalizedBase = parseIp(parts[0].trim()) ?: return null diff --git a/android/app/src/test/java/com/masterdns/vpn/ConfigGeneratorTest.kt b/android/app/src/test/java/com/masterdns/vpn/ConfigGeneratorTest.kt new file mode 100644 index 0000000..884b12d --- /dev/null +++ b/android/app/src/test/java/com/masterdns/vpn/ConfigGeneratorTest.kt @@ -0,0 +1,36 @@ +package com.masterdns.vpn + +import com.masterdns.vpn.util.ConfigGenerator +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConfigGeneratorTest { + @Test + fun escapeToml_plainStringKeptAsIs() { + assertEquals("hello world", ConfigGenerator.escapeToml("hello world")) + } + + @Test + fun escapeToml_quoteIsEscaped() { + assertEquals("a\\\"b", ConfigGenerator.escapeToml("a\"b")) + } + + @Test + fun escapeToml_backslashIsEscaped() { + assertEquals("a\\\\b", ConfigGenerator.escapeToml("a\\b")) + } + + @Test + fun escapeToml_newlineIsPassedThroughLiteral() { + // Documented current behavior: escapeToml does NOT escape newlines, + // so values containing \n can inject new top-level keys into the + // generated TOML. This test locks in that behavior so plan 008 + // (TOML whitelist) can flip it explicitly. + assertEquals("a\nb", ConfigGenerator.escapeToml("a\nb")) + } + + @Test + fun escapeToml_emptyString() { + assertEquals("", ConfigGenerator.escapeToml("")) + } +} diff --git a/android/app/src/test/java/com/masterdns/vpn/ResolverAnalyzerTest.kt b/android/app/src/test/java/com/masterdns/vpn/ResolverAnalyzerTest.kt new file mode 100644 index 0000000..2e73bca --- /dev/null +++ b/android/app/src/test/java/com/masterdns/vpn/ResolverAnalyzerTest.kt @@ -0,0 +1,97 @@ +package com.masterdns.vpn + +import com.masterdns.vpn.util.ResolverAnalyzer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ResolverAnalyzerTest { + private val analyzer = ResolverAnalyzer + + @Test + fun parseEntry_ipv4DefaultPort() { + val e = analyzer.parseEntry("1.2.3.4") + assertNotNull(e) + assertEquals("1.2.3.4", e!!.host) + assertEquals(53, e.port) + assertEquals(false, e.hasExplicitPort) + } + + @Test + fun parseEntry_ipv4ExplicitPort() { + val e = analyzer.parseEntry("1.2.3.4:5353") + assertNotNull(e) + assertEquals("1.2.3.4", e!!.host) + assertEquals(5353, e.port) + assertEquals(true, e.hasExplicitPort) + } + + @Test + fun parseEntry_ipv6BracketedWithPort() { + val e = analyzer.parseEntry("[::1]:53") + assertNotNull(e) + assertEquals("::1", e!!.host) + assertEquals(53, e.port) + } + + @Test + fun parseEntry_blankReturnsNull() { + assertNull(analyzer.parseEntry("")) + } + + @Test + fun parseEntry_portOutOfRangeFallsThroughToDefaultHostString() { + // Characterization of ACTUAL behavior: parseEntry does not reject + // out-of-range ports as null. For "1.2.3.4:65536" the canHavePort + // branch fails (65536 not in 1..65535) and execution falls through to + // ResolverEntry(text, DEFAULT_PORT, false) — i.e. the whole "1.2.3.4:65536" + // string becomes the host with default port 53 and hasExplicitPort=false. + // This is the behavior plan 008 (TOML validation) will tighten; lock it in. + val e = analyzer.parseEntry("1.2.3.4:65536") + assertNotNull("parseEntry returns non-null for out-of-range port — fall-through to default entry", e) + assertEquals("1.2.3.4:65536", e!!.host) + assertEquals(53, e.port) + assertEquals(false, e.hasExplicitPort) + } + + @Test + fun expandCidr_v4Slash30Yields2UsableHosts() { + val hosts = analyzer.expandCidr("192.168.1.0/30") ?: return + // /30 -> hostBits=2 -> total=4. For IPv4 prefixBits<31, usableStart=1, + // usableEndExclusive=total-1=3 -> offsets 1..2 -> 192.168.1.1 and .2. + assertEquals(2, hosts.size) + } + + @Test + fun expandCidr_hostBitsGT16ReturnsEmpty() { + // hostBits>16 hits the `if (hostBits > 16) return emptyList()` guard + // in ResolverAnalyzer.kt:220. For IPv4 /8: totalBits=32, prefixBits=8, + // hostBits=24 (>16) -> empty. + val hosts = analyzer.expandCidr("10.0.0.0/8") + assertTrue("expected empty (hostBits=24 > 16 guard)", hosts?.isEmpty() == true) + } + + @Test + fun parseIp_normalizes_ipv4() { + assertEquals("1.2.3.4", analyzer.parseIp("1.2.3.4")) + } + + @Test + fun parseIp_rejectsNonNumeric() { + assertNull(analyzer.parseIp("example.com")) + } + + @Test + fun analyzeAndNormalize_truncatedLargeInput() { + // MAX_IMPORT_BYTES = 2*1024*1024 = 2097152. Each "1.1.1.1\n" is 8 UTF-8 + // bytes (incl. the trailing newline). Use 300_000 repetitions -> + // 300_000 * 8 = 2_400_000 bytes > 2_097_152 -> early-return with + // truncated=true and empty normalizedText. + val big = "1.1.1.1\n".repeat(300_000) + val result = analyzer.analyzeAndNormalize(big, "big.txt") + assertNotNull(result) + assertEquals(true, result!!.stats.truncated) + } +} diff --git a/android/app/src/test/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategiesTest.kt b/android/app/src/test/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategiesTest.kt new file mode 100644 index 0000000..ae6ad27 --- /dev/null +++ b/android/app/src/test/java/com/masterdns/vpn/ui/settings/ResolverBalancingStrategiesTest.kt @@ -0,0 +1,12 @@ +package com.masterdns.vpn.ui.settings + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ResolverBalancingStrategiesTest { + @Test fun validValueUsesIt() { assertEquals(5, normalizeResolverBalancingStrategy("5", 2)) } + @Test fun zeroFallsBackTo2() { assertEquals(2, normalizeResolverBalancingStrategy("0", 3)) } + @Test fun nonNumericFallsBack() { assertEquals(3, normalizeResolverBalancingStrategy("abc", 3)) } + @Test fun outOfRangeFallsBack() { assertEquals(2, normalizeResolverBalancingStrategy("99", 2)) } + @Test fun nullAndInvalidFallbackYields2() { assertEquals(2, normalizeResolverBalancingStrategy(null, 99)) } +}