Skip to content

Commit fddf3bb

Browse files
committed
test(android): add unit test baseline + fix socksPort import validation
Add JUnit4/Truth test dependencies and 15 unit tests covering ConfigGenerator.generateConfig and ProfilesViewModel.parseProfileFromJson, establishing a regression baseline for the Android client. - ConfigGeneratorTest (8 tests): default fields, socks_port/user/pass omitempty, script_keys id|account parsing, sni handling, idle_slots_per_bucket always-present, coalesce_step_ms omitempty. - ProfilesViewModelParseTest (7 tests): empty/invalid JSON rejection, idleSlotsPerBucket clamping, socksPort privileged-port rejection, script_keys object+primitive forms, remoteUrl/name defaults. Bug fixes found during baseline creation: - parseProfileFromJson accepted invalid socks_port values (0, 99999, privileged ports 1-1023) without bounds checking. Clamp to non-privileged range 1024-65535 so malformed imports cannot produce a config that fails at SOCKS5 bind time. - Add testOptions.isReturnDefaultValues=true so android.util.Log calls in ConfigGenerator do not crash JVM unit tests. CI: add a 'Run unit tests' step to android-ci.yml that runs ./gradlew :app:testDebugUnitTest on every push and PR.
1 parent f14910b commit fddf3bb

5 files changed

Lines changed: 202 additions & 1 deletion

File tree

.github/workflows/android-ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ jobs:
3636
working-directory: android
3737
run: chmod +x gradlew && ./gradlew :app:assembleDebug --stacktrace
3838

39+
- name: Run unit tests
40+
working-directory: android
41+
run: chmod +x gradlew && ./gradlew :app:testDebugUnitTest --stacktrace
42+
3943
- name: Upload APK artifact
4044
uses: actions/upload-artifact@v4
4145
with:

android/app/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ android {
9393
buildConfig = true
9494
}
9595

96+
testOptions {
97+
unitTests {
98+
isReturnDefaultValues = true
99+
}
100+
}
101+
96102
splits {
97103
abi {
98104
// Generate split APKs plus a universal APK.
@@ -151,4 +157,10 @@ dependencies {
151157
// Debug
152158
debugImplementation("androidx.compose.ui:ui-tooling")
153159
debugImplementation("androidx.compose.ui:ui-test-manifest")
160+
161+
// Unit tests
162+
testImplementation("junit:junit:4.13.2")
163+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
164+
testImplementation("com.google.truth:truth:1.4.4")
165+
testImplementation("org.json:json:20240303")
154166
}

android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ class ProfilesViewModel @Inject constructor(
175175
val name = root.get("name")?.asString ?: defaultName ?: "Imported"
176176
val debugTiming = root.get("debug_timing")?.asBoolean ?: false
177177
val socksHost = root.get("socks_host")?.asString ?: "127.0.0.1"
178-
val socksPort = root.get("socks_port")?.asInt ?: 1080
178+
val socksPort = root.get("socks_port")?.asInt?.coerceIn(1024, 65535) ?: 1080
179179
val socksUser = root.get("socks_user")?.asString ?: ""
180180
val socksPass = root.get("socks_pass")?.asString ?: ""
181181
val googleHost = root.get("google_host")?.asString ?: "216.239.38.120"
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.gooserelay.gooserelayvpn
2+
3+
import com.google.gson.Gson
4+
import com.google.gson.JsonObject
5+
import com.google.common.truth.Truth.assertThat
6+
import com.gooserelay.gooserelayvpn.data.local.ProfileEntity
7+
import com.gooserelay.gooserelayvpn.util.ConfigGenerator
8+
import org.junit.Test
9+
10+
class ConfigGeneratorTest {
11+
private val gson = Gson()
12+
13+
private fun parse(json: String): JsonObject =
14+
gson.fromJson(json, JsonObject::class.java)
15+
16+
@Test
17+
fun `default profile emits required fields`() {
18+
val profile = ProfileEntity(name = "test")
19+
val root = parse(ConfigGenerator.generateConfig(profile))
20+
assertThat(root.has("socks_host")).isTrue()
21+
assertThat(root.has("google_host")).isTrue()
22+
assertThat(root.has("tunnel_key")).isTrue()
23+
assertThat(root.has("sni")).isTrue()
24+
assertThat(root.has("script_keys")).isTrue()
25+
assertThat(root.has("idle_slots_per_bucket")).isTrue()
26+
assertThat(root.getAsJsonArray("sni").size()).isGreaterThan(0)
27+
assertThat(root.getAsJsonArray("script_keys").size()).isEqualTo(0)
28+
}
29+
30+
@Test
31+
fun `socks_port is omitted when 1080 and present otherwise`() {
32+
val default = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x")))
33+
assertThat(default.has("socks_port")).isFalse()
34+
35+
val custom = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x", socksPort = 1234)))
36+
assertThat(custom.has("socks_port")).isTrue()
37+
assertThat(custom.get("socks_port").asInt).isEqualTo(1234)
38+
}
39+
40+
@Test
41+
fun `socks_user and socks_pass are omitted when blank`() {
42+
val blank = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x")))
43+
assertThat(blank.has("socks_user")).isFalse()
44+
assertThat(blank.has("socks_pass")).isFalse()
45+
46+
val filled = parse(ConfigGenerator.generateConfig(
47+
ProfileEntity(name = "x", socksUser = "alice", socksPass = "secret")
48+
))
49+
assertThat(filled.get("socks_user").asString).isEqualTo("alice")
50+
assertThat(filled.get("socks_pass").asString).isEqualTo("secret")
51+
}
52+
53+
@Test
54+
fun `script_keys parses id_pipe_account lines`() {
55+
val p = ProfileEntity(name = "x", scriptKeysText = "DEPLOY123\nACC456|user@example.com")
56+
val root = parse(ConfigGenerator.generateConfig(p))
57+
val arr = root.getAsJsonArray("script_keys")
58+
assertThat(arr.size()).isEqualTo(2)
59+
assertThat(arr[0].asJsonObject.get("id").asString).isEqualTo("DEPLOY123")
60+
assertThat(arr[1].asJsonObject.get("id").asString).isEqualTo("ACC456")
61+
assertThat(arr[1].asJsonObject.get("account").asString).isEqualTo("user@example.com")
62+
}
63+
64+
@Test
65+
fun `script_keys empty input produces empty array`() {
66+
val p = ProfileEntity(name = "x", scriptKeysText = "")
67+
val root = parse(ConfigGenerator.generateConfig(p))
68+
assertThat(root.getAsJsonArray("script_keys").size()).isEqualTo(0)
69+
}
70+
71+
@Test
72+
fun `sni handles blank and valid json array`() {
73+
val blank = ProfileEntity(name = "x", sniJson = "")
74+
val rootBlank = parse(ConfigGenerator.generateConfig(blank))
75+
assertThat(rootBlank.getAsJsonArray("sni").size()).isEqualTo(0)
76+
77+
val valid = ProfileEntity(name = "x", sniJson = "[\"example.com\"]")
78+
val rootValid = parse(ConfigGenerator.generateConfig(valid))
79+
assertThat(rootValid.getAsJsonArray("sni").size()).isEqualTo(1)
80+
assertThat(rootValid.getAsJsonArray("sni")[0].asString).isEqualTo("example.com")
81+
}
82+
83+
@Test
84+
fun `idle_slots_per_bucket is always present`() {
85+
val p = ProfileEntity(name = "x", idleSlotsPerBucket = 1)
86+
val root = parse(ConfigGenerator.generateConfig(p))
87+
assertThat(root.has("idle_slots_per_bucket")).isTrue()
88+
assertThat(root.get("idle_slots_per_bucket").asInt).isEqualTo(1)
89+
}
90+
91+
@Test
92+
fun `coalesce_step_ms is omitted when 0`() {
93+
val zero = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x")))
94+
assertThat(zero.has("coalesce_step_ms")).isFalse()
95+
96+
val nonzero = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x", coalesceStepMs = 500)))
97+
assertThat(nonzero.get("coalesce_step_ms").asInt).isEqualTo(500)
98+
}
99+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.gooserelay.gooserelayvpn
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import com.gooserelay.gooserelayvpn.data.local.ProfileDao
5+
import com.gooserelay.gooserelayvpn.data.local.ProfileEntity
6+
import com.gooserelay.gooserelayvpn.data.repository.ProfileRepository
7+
import com.gooserelay.gooserelayvpn.ui.profiles.ProfilesViewModel
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.emptyFlow
10+
import org.junit.Test
11+
12+
class ProfilesViewModelParseTest {
13+
14+
private fun vm() = ProfilesViewModel(ProfileRepository(NoopProfileDao()))
15+
16+
private class NoopProfileDao : ProfileDao {
17+
override fun getAllProfiles(): Flow<List<ProfileEntity>> = emptyFlow()
18+
override suspend fun getProfileById(id: Long): ProfileEntity? = null
19+
override fun getProfileByIdFlow(id: Long): Flow<ProfileEntity?> = emptyFlow()
20+
override suspend fun getSelectedProfile(): ProfileEntity? = null
21+
override fun getSelectedProfileFlow(): Flow<ProfileEntity?> = emptyFlow()
22+
override suspend fun getNewestProfile(): ProfileEntity? = null
23+
override suspend fun insertProfile(profile: ProfileEntity): Long = 0L
24+
override suspend fun updateProfile(profile: ProfileEntity) {}
25+
override suspend fun deleteProfile(profile: ProfileEntity) {}
26+
override suspend fun deselectAll() {}
27+
override suspend fun selectProfile(id: Long) {}
28+
override suspend fun setSelectedProfile(id: Long) {}
29+
}
30+
31+
@Test
32+
fun `returns null for empty json`() {
33+
assertThat(vm().parseProfileFromJson("")).isNull()
34+
}
35+
36+
@Test
37+
fun `returns null when json lacks script_keys and tunnel_key`() {
38+
val json = """{"name":"x","google_host":"1.2.3.4"}"""
39+
assertThat(vm().parseProfileFromJson(json)).isNull()
40+
}
41+
42+
@Test
43+
fun `clamps idleSlotsPerBucket to 1_3 range`() {
44+
val json = """{"tunnel_key":"k","idle_slots_per_bucket":99}"""
45+
val p = vm().parseProfileFromJson(json)
46+
assertThat(p).isNotNull()
47+
assertThat(p!!.idleSlotsPerBucket).isEqualTo(3)
48+
}
49+
50+
@Test
51+
fun `clamps socksPort to 1024_65535 range`() {
52+
val tooHigh = """{"tunnel_key":"k","socks_port":99999}"""
53+
assertThat(vm().parseProfileFromJson(tooHigh)!!.socksPort).isEqualTo(65535)
54+
55+
val tooLow = """{"tunnel_key":"k","socks_port":0}"""
56+
assertThat(vm().parseProfileFromJson(tooLow)!!.socksPort).isEqualTo(1024)
57+
58+
val privileged = """{"tunnel_key":"k","socks_port":80}"""
59+
assertThat(vm().parseProfileFromJson(privileged)!!.socksPort).isEqualTo(1024)
60+
}
61+
62+
@Test
63+
fun `accepts script_keys as object array and as primitive string`() {
64+
val objArr = """{"tunnel_key":"k","script_keys":[{"id":"A","account":"b@x.com"},{"id":"B"}]}"""
65+
val obj = vm().parseProfileFromJson(objArr)!!
66+
assertThat(obj.scriptKeysText).isEqualTo("A|b@x.com\nB")
67+
68+
val prim = """{"tunnel_key":"k","script_keys":"plain"}"""
69+
val primP = vm().parseProfileFromJson(prim)!!
70+
assertThat(primP.scriptKeysText).isEqualTo("plain")
71+
}
72+
73+
@Test
74+
fun `stores remoteUrl when provided`() {
75+
val json = """{"tunnel_key":"k"}"""
76+
val p = vm().parseProfileFromJson(json, remoteUrl = "https://example.com/p.json")!!
77+
assertThat(p.remoteUrl).isEqualTo("https://example.com/p.json")
78+
}
79+
80+
@Test
81+
fun `defaults name to defaultName when missing`() {
82+
val json = """{"tunnel_key":"k"}"""
83+
val p = vm().parseProfileFromJson(json, defaultName = "fallback")!!
84+
assertThat(p.name).isEqualTo("fallback")
85+
}
86+
}

0 commit comments

Comments
 (0)