From fddf3bb2c9cec40161cbbb34b25cfb189e416bb7 Mon Sep 17 00:00:00 2001 From: Hidden-Node Date: Sat, 18 Jul 2026 23:19:49 +0330 Subject: [PATCH] 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. --- .github/workflows/android-ci.yml | 4 + android/app/build.gradle.kts | 12 +++ .../ui/profiles/ProfilesViewModel.kt | 2 +- .../gooserelayvpn/ConfigGeneratorTest.kt | 99 +++++++++++++++++++ .../ProfilesViewModelParseTest.kt | 86 ++++++++++++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 android/app/src/test/java/com/gooserelay/gooserelayvpn/ConfigGeneratorTest.kt create mode 100644 android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index b460e33..caea295 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -36,6 +36,10 @@ jobs: working-directory: android run: chmod +x gradlew && ./gradlew :app:assembleDebug --stacktrace + - name: Run 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 115fdac..c6a77c4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -93,6 +93,12 @@ android { buildConfig = true } + testOptions { + unitTests { + isReturnDefaultValues = true + } + } + splits { abi { // Generate split APKs plus a universal APK. @@ -151,4 +157,10 @@ dependencies { // Debug debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") + + // Unit tests + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("com.google.truth:truth:1.4.4") + testImplementation("org.json:json:20240303") } diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt index cf0c92a..e3bcffb 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/profiles/ProfilesViewModel.kt @@ -175,7 +175,7 @@ class ProfilesViewModel @Inject constructor( val name = root.get("name")?.asString ?: defaultName ?: "Imported" val debugTiming = root.get("debug_timing")?.asBoolean ?: false val socksHost = root.get("socks_host")?.asString ?: "127.0.0.1" - val socksPort = root.get("socks_port")?.asInt ?: 1080 + val socksPort = root.get("socks_port")?.asInt?.coerceIn(1024, 65535) ?: 1080 val socksUser = root.get("socks_user")?.asString ?: "" val socksPass = root.get("socks_pass")?.asString ?: "" val googleHost = root.get("google_host")?.asString ?: "216.239.38.120" diff --git a/android/app/src/test/java/com/gooserelay/gooserelayvpn/ConfigGeneratorTest.kt b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ConfigGeneratorTest.kt new file mode 100644 index 0000000..15c4c81 --- /dev/null +++ b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ConfigGeneratorTest.kt @@ -0,0 +1,99 @@ +package com.gooserelay.gooserelayvpn + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.common.truth.Truth.assertThat +import com.gooserelay.gooserelayvpn.data.local.ProfileEntity +import com.gooserelay.gooserelayvpn.util.ConfigGenerator +import org.junit.Test + +class ConfigGeneratorTest { + private val gson = Gson() + + private fun parse(json: String): JsonObject = + gson.fromJson(json, JsonObject::class.java) + + @Test + fun `default profile emits required fields`() { + val profile = ProfileEntity(name = "test") + val root = parse(ConfigGenerator.generateConfig(profile)) + assertThat(root.has("socks_host")).isTrue() + assertThat(root.has("google_host")).isTrue() + assertThat(root.has("tunnel_key")).isTrue() + assertThat(root.has("sni")).isTrue() + assertThat(root.has("script_keys")).isTrue() + assertThat(root.has("idle_slots_per_bucket")).isTrue() + assertThat(root.getAsJsonArray("sni").size()).isGreaterThan(0) + assertThat(root.getAsJsonArray("script_keys").size()).isEqualTo(0) + } + + @Test + fun `socks_port is omitted when 1080 and present otherwise`() { + val default = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x"))) + assertThat(default.has("socks_port")).isFalse() + + val custom = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x", socksPort = 1234))) + assertThat(custom.has("socks_port")).isTrue() + assertThat(custom.get("socks_port").asInt).isEqualTo(1234) + } + + @Test + fun `socks_user and socks_pass are omitted when blank`() { + val blank = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x"))) + assertThat(blank.has("socks_user")).isFalse() + assertThat(blank.has("socks_pass")).isFalse() + + val filled = parse(ConfigGenerator.generateConfig( + ProfileEntity(name = "x", socksUser = "alice", socksPass = "secret") + )) + assertThat(filled.get("socks_user").asString).isEqualTo("alice") + assertThat(filled.get("socks_pass").asString).isEqualTo("secret") + } + + @Test + fun `script_keys parses id_pipe_account lines`() { + val p = ProfileEntity(name = "x", scriptKeysText = "DEPLOY123\nACC456|user@example.com") + val root = parse(ConfigGenerator.generateConfig(p)) + val arr = root.getAsJsonArray("script_keys") + assertThat(arr.size()).isEqualTo(2) + assertThat(arr[0].asJsonObject.get("id").asString).isEqualTo("DEPLOY123") + assertThat(arr[1].asJsonObject.get("id").asString).isEqualTo("ACC456") + assertThat(arr[1].asJsonObject.get("account").asString).isEqualTo("user@example.com") + } + + @Test + fun `script_keys empty input produces empty array`() { + val p = ProfileEntity(name = "x", scriptKeysText = "") + val root = parse(ConfigGenerator.generateConfig(p)) + assertThat(root.getAsJsonArray("script_keys").size()).isEqualTo(0) + } + + @Test + fun `sni handles blank and valid json array`() { + val blank = ProfileEntity(name = "x", sniJson = "") + val rootBlank = parse(ConfigGenerator.generateConfig(blank)) + assertThat(rootBlank.getAsJsonArray("sni").size()).isEqualTo(0) + + val valid = ProfileEntity(name = "x", sniJson = "[\"example.com\"]") + val rootValid = parse(ConfigGenerator.generateConfig(valid)) + assertThat(rootValid.getAsJsonArray("sni").size()).isEqualTo(1) + assertThat(rootValid.getAsJsonArray("sni")[0].asString).isEqualTo("example.com") + } + + @Test + fun `idle_slots_per_bucket is always present`() { + val p = ProfileEntity(name = "x", idleSlotsPerBucket = 1) + val root = parse(ConfigGenerator.generateConfig(p)) + assertThat(root.has("idle_slots_per_bucket")).isTrue() + assertThat(root.get("idle_slots_per_bucket").asInt).isEqualTo(1) + } + + @Test + fun `coalesce_step_ms is omitted when 0`() { + val zero = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x"))) + assertThat(zero.has("coalesce_step_ms")).isFalse() + + val nonzero = parse(ConfigGenerator.generateConfig(ProfileEntity(name = "x", coalesceStepMs = 500))) + assertThat(nonzero.get("coalesce_step_ms").asInt).isEqualTo(500) + } +} diff --git a/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt new file mode 100644 index 0000000..86c15c1 --- /dev/null +++ b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt @@ -0,0 +1,86 @@ +package com.gooserelay.gooserelayvpn + +import com.google.common.truth.Truth.assertThat +import com.gooserelay.gooserelayvpn.data.local.ProfileDao +import com.gooserelay.gooserelayvpn.data.local.ProfileEntity +import com.gooserelay.gooserelayvpn.data.repository.ProfileRepository +import com.gooserelay.gooserelayvpn.ui.profiles.ProfilesViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Test + +class ProfilesViewModelParseTest { + + private fun vm() = ProfilesViewModel(ProfileRepository(NoopProfileDao())) + + private class NoopProfileDao : ProfileDao { + override fun getAllProfiles(): Flow> = emptyFlow() + override suspend fun getProfileById(id: Long): ProfileEntity? = null + override fun getProfileByIdFlow(id: Long): Flow = emptyFlow() + override suspend fun getSelectedProfile(): ProfileEntity? = null + override fun getSelectedProfileFlow(): Flow = emptyFlow() + override suspend fun getNewestProfile(): ProfileEntity? = null + override suspend fun insertProfile(profile: ProfileEntity): Long = 0L + override suspend fun updateProfile(profile: ProfileEntity) {} + override suspend fun deleteProfile(profile: ProfileEntity) {} + override suspend fun deselectAll() {} + override suspend fun selectProfile(id: Long) {} + override suspend fun setSelectedProfile(id: Long) {} + } + + @Test + fun `returns null for empty json`() { + assertThat(vm().parseProfileFromJson("")).isNull() + } + + @Test + fun `returns null when json lacks script_keys and tunnel_key`() { + val json = """{"name":"x","google_host":"1.2.3.4"}""" + assertThat(vm().parseProfileFromJson(json)).isNull() + } + + @Test + fun `clamps idleSlotsPerBucket to 1_3 range`() { + val json = """{"tunnel_key":"k","idle_slots_per_bucket":99}""" + val p = vm().parseProfileFromJson(json) + assertThat(p).isNotNull() + assertThat(p!!.idleSlotsPerBucket).isEqualTo(3) + } + + @Test + fun `clamps socksPort to 1024_65535 range`() { + val tooHigh = """{"tunnel_key":"k","socks_port":99999}""" + assertThat(vm().parseProfileFromJson(tooHigh)!!.socksPort).isEqualTo(65535) + + val tooLow = """{"tunnel_key":"k","socks_port":0}""" + assertThat(vm().parseProfileFromJson(tooLow)!!.socksPort).isEqualTo(1024) + + val privileged = """{"tunnel_key":"k","socks_port":80}""" + assertThat(vm().parseProfileFromJson(privileged)!!.socksPort).isEqualTo(1024) + } + + @Test + fun `accepts script_keys as object array and as primitive string`() { + val objArr = """{"tunnel_key":"k","script_keys":[{"id":"A","account":"b@x.com"},{"id":"B"}]}""" + val obj = vm().parseProfileFromJson(objArr)!! + assertThat(obj.scriptKeysText).isEqualTo("A|b@x.com\nB") + + val prim = """{"tunnel_key":"k","script_keys":"plain"}""" + val primP = vm().parseProfileFromJson(prim)!! + assertThat(primP.scriptKeysText).isEqualTo("plain") + } + + @Test + fun `stores remoteUrl when provided`() { + val json = """{"tunnel_key":"k"}""" + val p = vm().parseProfileFromJson(json, remoteUrl = "https://example.com/p.json")!! + assertThat(p.remoteUrl).isEqualTo("https://example.com/p.json") + } + + @Test + fun `defaults name to defaultName when missing`() { + val json = """{"tunnel_key":"k"}""" + val p = vm().parseProfileFromJson(json, defaultName = "fallback")!! + assertThat(p.name).isEqualTo("fallback") + } +}