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 @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ android {
buildConfig = true
}

testOptions {
unitTests {
isReturnDefaultValues = true
}
}

splits {
abi {
// Generate split APKs plus a universal APK.
Expand Down Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<List<ProfileEntity>> = emptyFlow()
override suspend fun getProfileById(id: Long): ProfileEntity? = null
override fun getProfileByIdFlow(id: Long): Flow<ProfileEntity?> = emptyFlow()
override suspend fun getSelectedProfile(): ProfileEntity? = null
override fun getSelectedProfileFlow(): Flow<ProfileEntity?> = 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")
}
}
Loading