Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>): ProfileEntity {
val mergedAdvanced = parseAdvanced(profile.advancedJson).toMutableMap()
values.forEach { (key, value) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ object ConfigGenerator {
}
}

private fun escapeToml(s: String): String {
internal fun escapeToml(s: String): String {
return s.replace("\\", "\\\\").replace("\"", "\\\"")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("[")) {
Expand All @@ -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}"))
Expand All @@ -207,7 +207,7 @@ object ResolverAnalyzer {
return runCatching { InetAddress.getByName(text).hostAddress }.getOrNull()
}

private fun expandCidr(value: String): List<String>? {
internal fun expandCidr(value: String): List<String>? {
val parts = value.split("/")
if (parts.size != 2) return null
val normalizedBase = parseIp(parts[0].trim()) ?: return null
Expand Down
36 changes: 36 additions & 0 deletions android/app/src/test/java/com/masterdns/vpn/ConfigGeneratorTest.kt
Original file line number Diff line number Diff line change
@@ -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(""))
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)) }
}
Loading