mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
test(tor): tier-1 TorManager unit tests + tier-3 instrumented scaffold
Tier 1 — 18 fast unit tests for the self-heal logic, virtual time only:
- Extracted TorBackend interface (status + start/stop/reset/resetWithCleanState),
TorService implements it. TorManager now takes a TorBackend by injection
rather than constructing a TorService itself.
- Extracted TorPreferencesPort (torType + externalSocksPort flows + load/save
bypass-approval). TorSharedPreferences implements it via forwarding properties.
- Injected ioDispatcher (default Dispatchers.IO) and nowMs clock (default
System::currentTimeMillis) so tests drive the 45s watchdog + 5-min cooldown
in milliseconds of virtual time.
- Tests cover: persisted-approval load, torType-change bypass clear,
approveBypassForOneHour, onNetworkChange (clear + reset + cooldown prime),
watchdog gentle-reset before first Active, watchdog full-reset after Active,
watchdog cancellation on Active, cooldown blocks within window + permits
outside, status routing for OFF/EXTERNAL/INTERNAL, sessionBypass forcing Off,
activePortOrNull mirroring.
- Uses UnconfinedTestDispatcher inside runTest — flowOn(ioDispatcher) +
WhileSubscribed cross-dispatcher channel needs eager dispatch for
MutableStateFlow.value updates to propagate through advanceUntilIdle.
Tier 3 — TorBootstrapInstrumentedTest scaffold (@LargeTest, @Ignore by default):
- Cold-start bootstrap: TorService.start → first { Active } within 120s.
- HTTPS round-trip: OkHttp via SOCKS to check.torproject.org, asserts IsTor:true.
This is the regression net for the rustls CryptoProvider install after the
Arti bump and for the destroy/handler abort race in the Rust shim.
- reset → re-start: verifies the state-file-lock is released so the second
TorService.start can re-create the TorClient cleanly.
- KDoc documents how to enable + run on a real device (the test needs Tor
network egress + 60–120s of wall-clock per case, hence default-Ignored).
No production behavior changes — only injection seams + interfaces.
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.tor
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorService
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
/**
|
||||
* Real-Arti bootstrap + SOCKS round trip on-device. Verifies that the self-heal /
|
||||
* destroy / re-init paths work end-to-end against the actual native lib.
|
||||
*
|
||||
* **This test is [Ignore]'d by default** because:
|
||||
* - It needs network egress to the Tor network from the device/emulator. Many CI
|
||||
* environments don't have it.
|
||||
* - Bootstrap on a cold device can take 30-120s; the test costs real wall-clock time.
|
||||
* - It depends on `check.torproject.org` being reachable.
|
||||
*
|
||||
* **To run manually:**
|
||||
* 1. Connect a device or start an emulator that has internet egress to Tor.
|
||||
* 2. Remove the `@Ignore` annotation below.
|
||||
* 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest`
|
||||
*
|
||||
* **What it covers that [TorManagerTest] does not:**
|
||||
* - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind.
|
||||
* - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump).
|
||||
* - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds.
|
||||
* - OkHttp routing traffic through the SOCKS port and Arti exiting through the
|
||||
* Tor network.
|
||||
*
|
||||
* **Companion fast tests:** `amethyst/src/test/.../tor/TorManagerTest.kt` covers the
|
||||
* Kotlin-side self-heal logic (watchdog, cooldown, network change, status routing)
|
||||
* with virtual time and in-memory fakes — no Arti required.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.")
|
||||
class TorBootstrapInstrumentedTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val torService = TorService(context)
|
||||
|
||||
@After
|
||||
fun tearDown() =
|
||||
runBlocking {
|
||||
// Drop the native client so this test's state file lock doesn't bleed into
|
||||
// the next instrumented run on the same device.
|
||||
torService.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-start bootstrap. The whole point of the custom Arti build is that this
|
||||
* works at all — if create_bootstrapped panics (e.g., because we forgot to install
|
||||
* a rustls CryptoProvider after an arti bump) the test catches it.
|
||||
*/
|
||||
@Test
|
||||
fun `bootstraps to Active within 120s`() =
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val elapsed =
|
||||
measureTimeMillis {
|
||||
torService.start()
|
||||
val active =
|
||||
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
|
||||
torService.status.first { it is TorServiceStatus.Active }
|
||||
} as TorServiceStatus.Active
|
||||
assertTrue("SOCKS port should be > 0", active.port > 0)
|
||||
}
|
||||
// Logged via assertEquals failure-on-too-slow; an actual `Log.i` would be invisible.
|
||||
// Bootstrap should comfortably fit in 120s on a healthy network.
|
||||
assertTrue("Bootstrap took ${elapsed}ms, expected < ${BOOTSTRAP_TIMEOUT_MS}ms", elapsed < BOOTSTRAP_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* SOCKS round-trip through Tor. Hits `check.torproject.org` which returns a JSON
|
||||
* payload including `"IsTor":true` when the request actually exited via Tor.
|
||||
* Catches regressions where the listener binds but no traffic flows (e.g., a
|
||||
* broken handler-spawn race, or a crypto provider mismatch on the TLS handshake).
|
||||
*/
|
||||
@Test
|
||||
fun `proxies HTTPS through Tor and reports IsTor true`() =
|
||||
runBlocking(Dispatchers.IO) {
|
||||
torService.start()
|
||||
val active =
|
||||
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
|
||||
torService.status.first { it is TorServiceStatus.Active }
|
||||
} as TorServiceStatus.Active
|
||||
|
||||
val client =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", active.port)))
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("https://check.torproject.org/api/ip")
|
||||
.build()
|
||||
|
||||
val body =
|
||||
client.newCall(request).execute().use { resp ->
|
||||
assertEquals("HTTP 200", 200, resp.code)
|
||||
resp.body.string()
|
||||
}
|
||||
assertTrue(
|
||||
"Response should report IsTor:true — actual body: $body",
|
||||
body.contains("\"IsTor\":true"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the destroy → re-init cycle that backs the self-heal path. After
|
||||
* [TorService.reset], the next [TorService.start] must rebuild the TorClient and
|
||||
* bring SOCKS back to Active — without a "state file already locked" error from
|
||||
* the still-alive previous client.
|
||||
*/
|
||||
@Test
|
||||
fun `reset then re-start brings SOCKS back to Active`() =
|
||||
runBlocking(Dispatchers.IO) {
|
||||
torService.start()
|
||||
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
|
||||
torService.status.first { it is TorServiceStatus.Active }
|
||||
}
|
||||
|
||||
torService.reset()
|
||||
assertEquals(TorServiceStatus.Off, torService.status.value)
|
||||
|
||||
torService.start()
|
||||
val second =
|
||||
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
|
||||
torService.status.first { it is TorServiceStatus.Active }
|
||||
} as TorServiceStatus.Active
|
||||
assertTrue("Second bootstrap port valid", second.port > 0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorService
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
@@ -190,7 +191,7 @@ class AppModules(
|
||||
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
|
||||
}
|
||||
|
||||
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
|
||||
val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope)
|
||||
|
||||
// Network identity change (wifi↔cellular, regained from offline, captive portal
|
||||
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
|
||||
|
||||
+8
-3
@@ -29,12 +29,14 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorPreferencesPort
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -48,10 +50,13 @@ class TorSharedPreferences(
|
||||
prefs: TorSettings,
|
||||
val context: Context,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
) : TorPreferencesPort {
|
||||
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
|
||||
val value = TorSettingsFlow.build(prefs)
|
||||
|
||||
override val torType: StateFlow<TorType> get() = value.torType
|
||||
override val externalSocksPort: StateFlow<Int> get() = value.externalSocksPort
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val saving =
|
||||
value.propertyWatchFlow
|
||||
@@ -66,9 +71,9 @@ class TorSharedPreferences(
|
||||
value.toSettings(),
|
||||
)
|
||||
|
||||
suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context)
|
||||
override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context)
|
||||
|
||||
suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context)
|
||||
override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context)
|
||||
|
||||
companion object {
|
||||
// loads faster when individualized
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The slice of [TorService] that [TorManager] drives. Extracted so the manager
|
||||
* can be unit-tested without booting Arti via JNI — production wires
|
||||
* `TorService(context)`, tests wire an in-memory fake.
|
||||
*/
|
||||
interface TorBackend {
|
||||
val status: StateFlow<TorServiceStatus>
|
||||
|
||||
suspend fun start()
|
||||
|
||||
suspend fun stop()
|
||||
|
||||
suspend fun reset()
|
||||
|
||||
suspend fun resetWithCleanState()
|
||||
}
|
||||
@@ -20,10 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -48,14 +47,19 @@ import kotlinx.coroutines.launch
|
||||
* There should be only one instance of the Tor binding per app.
|
||||
*
|
||||
* Tor will connect as soon as status is listened to.
|
||||
*
|
||||
* [service] and [torPrefs] are constructor-injected so the manager can be unit-tested
|
||||
* with in-memory fakes — see `TorManagerTest`. [ioDispatcher] is the dispatcher for
|
||||
* background I/O (DataStore reads/writes, [TorBackend] calls); tests pass a
|
||||
* `TestDispatcher` so virtual time controls scheduling.
|
||||
*/
|
||||
class TorManager(
|
||||
private val torPrefs: TorSharedPreferences,
|
||||
app: Context,
|
||||
private val torPrefs: TorPreferencesPort,
|
||||
val service: TorBackend,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val nowMs: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
val service = TorService(app)
|
||||
|
||||
/**
|
||||
* In-memory only — when true, the manager emits [TorServiceStatus.Off] regardless of
|
||||
* the persisted [TorType]. Cleared on process death, on network change, and on any
|
||||
@@ -93,7 +97,7 @@ class TorManager(
|
||||
@Volatile private var hasEverBootstrapped: Boolean = false
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
scope.launch(ioDispatcher) {
|
||||
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
|
||||
}
|
||||
|
||||
@@ -103,7 +107,7 @@ class TorManager(
|
||||
// auto-flips sessionBypass without showing the dialog, force-stop preserves
|
||||
// the DataStore-backed approval, and toggling Tor off/on only clears the
|
||||
// in-memory half — so wiping app data becomes the only recovery path.
|
||||
torPrefs.value.torType
|
||||
torPrefs.torType
|
||||
.drop(1)
|
||||
.onEach {
|
||||
sessionBypass.value = false
|
||||
@@ -115,8 +119,8 @@ class TorManager(
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val status =
|
||||
combine(
|
||||
torPrefs.value.torType,
|
||||
torPrefs.value.externalSocksPort,
|
||||
torPrefs.torType,
|
||||
torPrefs.externalSocksPort,
|
||||
sessionBypass,
|
||||
resetEpoch,
|
||||
) { torType, externalSocksPort, bypass, _ ->
|
||||
@@ -150,7 +154,7 @@ class TorManager(
|
||||
}.catch { e ->
|
||||
Log.e("TorManager") { "Tor service error: ${e.message}" }
|
||||
emit(TorServiceStatus.Off)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}.flowOn(ioDispatcher)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(30000),
|
||||
@@ -231,7 +235,7 @@ class TorManager(
|
||||
|
||||
selfHealSignal
|
||||
.onEach {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = nowMs()
|
||||
if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return@onEach
|
||||
lastSelfHealAtMs = now
|
||||
if (hasEverBootstrapped) {
|
||||
@@ -247,15 +251,15 @@ class TorManager(
|
||||
|
||||
fun rememberedApprovalActive(): Boolean {
|
||||
val ts = lastBypassApprovalMs
|
||||
return ts > 0 && (System.currentTimeMillis() - ts) < APPROVAL_REMEMBER_MS
|
||||
return ts > 0 && (nowMs() - ts) < APPROVAL_REMEMBER_MS
|
||||
}
|
||||
|
||||
/** Called when the user picks "Use regular connection". Starts a fresh 1-hour window. */
|
||||
fun approveBypassForOneHour() {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = nowMs()
|
||||
lastBypassApprovalMs = now
|
||||
sessionBypass.value = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
scope.launch(ioDispatcher) {
|
||||
torPrefs.saveLastBypassApprovalMs(now)
|
||||
}
|
||||
}
|
||||
@@ -275,8 +279,8 @@ class TorManager(
|
||||
// Prevent the stuck-Connecting watchdog from firing a second reset while the
|
||||
// network-change bootstrap is still legitimately in progress (initial bootstrap
|
||||
// on a new network can take ~10–30s, sometimes longer).
|
||||
lastSelfHealAtMs = System.currentTimeMillis()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
lastSelfHealAtMs = nowMs()
|
||||
scope.launch(ioDispatcher) {
|
||||
torPrefs.saveLastBypassApprovalMs(0L)
|
||||
service.reset()
|
||||
resetEpoch.update { it + 1 }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The slice of `TorSharedPreferences` that [TorManager] depends on. Extracted so the
|
||||
* manager can be unit-tested without an Android `Context` (and without DataStore).
|
||||
* Production wires `TorSharedPreferences`; tests wire an in-memory fake.
|
||||
*/
|
||||
interface TorPreferencesPort {
|
||||
val torType: StateFlow<TorType>
|
||||
val externalSocksPort: StateFlow<Int>
|
||||
|
||||
suspend fun loadLastBypassApprovalMs(): Long
|
||||
|
||||
suspend fun saveLastBypassApprovalMs(value: Long)
|
||||
}
|
||||
@@ -46,13 +46,13 @@ private const val MAX_PORT_RETRIES = 10
|
||||
*/
|
||||
class TorService(
|
||||
val context: Context,
|
||||
) {
|
||||
) : TorBackend {
|
||||
private var socksPort = DEFAULT_SOCKS_PORT
|
||||
private val initialized = AtomicBoolean(false)
|
||||
private val proxyRunning = AtomicBoolean(false)
|
||||
|
||||
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||
val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||
|
||||
private fun artiDataDir() = File(context.filesDir, "arti")
|
||||
|
||||
@@ -86,7 +86,7 @@ class TorService(
|
||||
* Initialize the TorClient (once) and start the SOCKS proxy.
|
||||
* Must be called from a coroutine on [Dispatchers.IO].
|
||||
*/
|
||||
suspend fun start() {
|
||||
override suspend fun start() {
|
||||
if (proxyRunning.get()) {
|
||||
if (_status.value is TorServiceStatus.Active) return
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
@@ -167,7 +167,7 @@ class TorService(
|
||||
* Stop the SOCKS proxy and release the port.
|
||||
* The TorClient stays alive — no file lock issues on restart.
|
||||
*/
|
||||
suspend fun stop() {
|
||||
override suspend fun stop() {
|
||||
if (!proxyRunning.compareAndSet(true, false)) return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -185,7 +185,7 @@ class TorService(
|
||||
* recovery — when the in-memory Arti state is suspected of being broken.
|
||||
* The `arti/state/` directory on disk is preserved.
|
||||
*/
|
||||
suspend fun reset() {
|
||||
override suspend fun reset() {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (proxyRunning.compareAndSet(true, false)) {
|
||||
ArtiNative.stopSocksProxy()
|
||||
@@ -203,7 +203,7 @@ class TorService(
|
||||
* on-disk state (e.g. unreachable guards persisted from a previous
|
||||
* network) is the suspected cause of a bootstrap that never completes.
|
||||
*/
|
||||
suspend fun resetWithCleanState() {
|
||||
override suspend fun resetWithCleanState() {
|
||||
reset()
|
||||
withContext(Dispatchers.IO) {
|
||||
clearAllArtiData()
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [TorManager]'s self-heal logic. Drives the manager with in-memory
|
||||
* [TorBackend] + [TorPreferencesPort] fakes and a virtual clock so the 45s watchdog
|
||||
* delay and 5-min cooldown can be exercised in milliseconds.
|
||||
*
|
||||
* Companion integration test in `amethyst/src/androidTest/.../tor/TorBootstrapInstrumentedTest.kt`
|
||||
* covers the real-Arti bootstrap path on-device (currently @Ignore'd; see file for enable steps).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TorManagerTest {
|
||||
// ------------------------------------------------------------------
|
||||
// construction + persisted state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `init loads persisted bypass approval`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val recent = 1_000_000_000_000L
|
||||
val prefs = FakeTorPreferences(initialApprovalMs = recent)
|
||||
val manager = buildManager(prefs = prefs, clock = { recent + 1_000L })
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(manager.rememberedApprovalActive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init does not flag approval when none persisted`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val manager = buildManager()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(manager.rememberedApprovalActive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rememberedApprovalActive is false once outside the 1h window`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val now = 1_000_000_000_000L
|
||||
val tooOld = now - TorManager.APPROVAL_REMEMBER_MS - 1L
|
||||
val prefs = FakeTorPreferences(initialApprovalMs = tooOld)
|
||||
val manager = buildManager(prefs = prefs, clock = { now })
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(manager.rememberedApprovalActive())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// torType change clears the bypass loop
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `torType change clears in-memory bypass and persisted approval`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prefs = FakeTorPreferences(initialApprovalMs = 999L)
|
||||
val manager = buildManager(prefs = prefs)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.sessionBypass.value = true
|
||||
|
||||
prefs.setTorType(TorType.OFF)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(manager.sessionBypass.value)
|
||||
assertEquals(0L, prefs.lastBypassApprovalMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approveBypassForOneHour sets sessionBypass and persists timestamp`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val now = 1_000_000_000_000L
|
||||
val prefs = FakeTorPreferences()
|
||||
val manager = buildManager(prefs = prefs, clock = { now })
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.approveBypassForOneHour()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(manager.sessionBypass.value)
|
||||
assertEquals(now, prefs.lastBypassApprovalMs)
|
||||
assertTrue(manager.rememberedApprovalActive())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// onNetworkChange — drops client + clears bypass + primes cooldown
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `onNetworkChange clears bypass and persisted approval and resets backend`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prefs = FakeTorPreferences(initialApprovalMs = 12345L)
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(prefs = prefs, backend = backend)
|
||||
advanceUntilIdle()
|
||||
manager.sessionBypass.value = true
|
||||
|
||||
manager.onNetworkChange()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(manager.sessionBypass.value)
|
||||
assertEquals(0L, prefs.lastBypassApprovalMs)
|
||||
assertTrue("onNetworkChange should reset backend at least once", backend.resetCount >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onNetworkChange primes cooldown so the watchdog does not double-reset`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
// Constant clock — the only way self-heal would fire is if onNetworkChange
|
||||
// failed to prime lastSelfHealAtMs.
|
||||
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
|
||||
advanceUntilIdle()
|
||||
val resetCountBefore = backend.resetCount
|
||||
|
||||
manager.onNetworkChange()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Status is back at Connecting after the network-change reset cycle.
|
||||
// Advance past the 45s watchdog; cooldown must suppress a second reset.
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
|
||||
// Exactly one extra reset from onNetworkChange itself, none from the watchdog.
|
||||
assertEquals(resetCountBefore + 1, backend.resetCount)
|
||||
assertEquals(0, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// stuck-Connecting watchdog
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `watchdog uses gentle reset before first Active`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
// Big constant clock so (now - lastSelfHealAtMs=0) is well past cooldown.
|
||||
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(TorServiceStatus.Connecting, manager.status.value)
|
||||
assertEquals(0, backend.resetCount)
|
||||
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals("gentle reset only — no state wipe before first Active", 1, backend.resetCount)
|
||||
assertEquals(0, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watchdog uses full reset after first Active`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
|
||||
advanceUntilIdle()
|
||||
|
||||
// Drive backend to Active so hasEverBootstrapped flips.
|
||||
backend.setActive(9050)
|
||||
advanceUntilIdle()
|
||||
assertTrue(manager.status.value is TorServiceStatus.Active)
|
||||
|
||||
// Back to Connecting — watchdog timer (re-)starts.
|
||||
backend.setConnecting()
|
||||
advanceUntilIdle()
|
||||
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(0, backend.resetCount)
|
||||
assertEquals("after Active, watchdog wipes state too", 1, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watchdog cancels its delay when status leaves Connecting`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
|
||||
advanceUntilIdle()
|
||||
|
||||
// Halfway through the watchdog delay, the bootstrap succeeds.
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS / 2)
|
||||
backend.setActive(9050)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Past the original deadline — must NOT fire.
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(0, backend.resetCount)
|
||||
assertEquals(0, backend.resetWithCleanStateCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watchdog cooldown blocks a second fire within the window`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
var clockNow = 1_000_000_000_000L
|
||||
val manager = buildManager(backend = backend, clock = { clockNow })
|
||||
advanceUntilIdle()
|
||||
|
||||
// First fire.
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
assertEquals(1, backend.resetCount)
|
||||
|
||||
// Status returns to Connecting via the reset → re-start cycle. Advance another
|
||||
// 45s of virtual time — clock has barely moved, so cooldown must block.
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
assertEquals("cooldown should suppress the second fire", 1, backend.resetCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watchdog can fire again once the cooldown elapses`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
var clockNow = 1_000_000_000_000L
|
||||
val manager = buildManager(backend = backend, clock = { clockNow })
|
||||
advanceUntilIdle()
|
||||
|
||||
// First fire.
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
assertEquals(1, backend.resetCount)
|
||||
|
||||
// Move wall-clock past the cooldown window.
|
||||
clockNow += TorManager.SELF_HEAL_COOLDOWN_MS + 1_000L
|
||||
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals("after cooldown elapses, watchdog fires again", 2, backend.resetCount)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// top-level status routing
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `status emits Off when torType is OFF`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prefs = FakeTorPreferences(initialTorType = TorType.OFF)
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(prefs = prefs, backend = backend)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(TorServiceStatus.Off, manager.status.value)
|
||||
assertEquals(0, backend.startCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status emits Active(port) for EXTERNAL with valid port`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 9150)
|
||||
val manager = buildManager(prefs = prefs)
|
||||
advanceUntilIdle()
|
||||
|
||||
val status = manager.status.value
|
||||
assertTrue(status is TorServiceStatus.Active)
|
||||
assertEquals(9150, (status as TorServiceStatus.Active).port)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status emits Off for EXTERNAL when port is invalid`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 0)
|
||||
val manager = buildManager(prefs = prefs)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(TorServiceStatus.Off, manager.status.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status follows backend status under INTERNAL`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(backend = backend)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(TorServiceStatus.Connecting, manager.status.value)
|
||||
|
||||
backend.setActive(17392)
|
||||
advanceUntilIdle()
|
||||
assertEquals(TorServiceStatus.Active(17392), manager.status.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activePortOrNull mirrors the Active port`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(backend = backend)
|
||||
// activePortOrNull is WhileSubscribed — give it a subscriber for the test.
|
||||
val portJob = backgroundScope.launch { manager.activePortOrNull.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
backend.setActive(17392)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(17392, manager.activePortOrNull.value)
|
||||
portJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sessionBypass forces Off even with torType INTERNAL`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val backend = FakeTorBackend()
|
||||
val manager = buildManager(backend = backend)
|
||||
advanceUntilIdle()
|
||||
backend.setActive(17392)
|
||||
advanceUntilIdle()
|
||||
assertNotEquals(TorServiceStatus.Off, manager.status.value)
|
||||
|
||||
manager.sessionBypass.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(TorServiceStatus.Off, manager.status.value)
|
||||
assertTrue(backend.stopCount >= 1)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private fun TestScope.buildManager(
|
||||
prefs: FakeTorPreferences = FakeTorPreferences(),
|
||||
backend: FakeTorBackend = FakeTorBackend(),
|
||||
clock: () -> Long = { 1_000_000_000_000L },
|
||||
): TorManager =
|
||||
TorManager(
|
||||
torPrefs = prefs,
|
||||
service = backend,
|
||||
scope = backgroundScope,
|
||||
// Unconfined so `MutableStateFlow.value = …` propagates through `flowOn`
|
||||
// synchronously — otherwise advanceUntilIdle never settles the cross-dispatcher
|
||||
// channel and `manager.status.value` is observed as the stateIn initial (Off).
|
||||
ioDispatcher = UnconfinedTestDispatcher(testScheduler),
|
||||
nowMs = clock,
|
||||
)
|
||||
}
|
||||
|
||||
/** In-memory [TorBackend] driven by tests. */
|
||||
private class FakeTorBackend : TorBackend {
|
||||
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||
|
||||
var startCount = 0
|
||||
private set
|
||||
var stopCount = 0
|
||||
private set
|
||||
var resetCount = 0
|
||||
private set
|
||||
var resetWithCleanStateCount = 0
|
||||
private set
|
||||
|
||||
override suspend fun start() {
|
||||
startCount++
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
stopCount++
|
||||
_status.value = TorServiceStatus.Off
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
resetCount++
|
||||
_status.value = TorServiceStatus.Off
|
||||
}
|
||||
|
||||
override suspend fun resetWithCleanState() {
|
||||
resetWithCleanStateCount++
|
||||
_status.value = TorServiceStatus.Off
|
||||
}
|
||||
|
||||
fun setActive(port: Int) {
|
||||
_status.value = TorServiceStatus.Active(port)
|
||||
}
|
||||
|
||||
fun setConnecting() {
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory [TorPreferencesPort] driven by tests. */
|
||||
private class FakeTorPreferences(
|
||||
initialTorType: TorType = TorType.INTERNAL,
|
||||
initialPort: Int = 9050,
|
||||
initialApprovalMs: Long = 0L,
|
||||
) : TorPreferencesPort {
|
||||
private val _torType = MutableStateFlow(initialTorType)
|
||||
private val _externalSocksPort = MutableStateFlow(initialPort)
|
||||
|
||||
override val torType: StateFlow<TorType> = _torType.asStateFlow()
|
||||
override val externalSocksPort: StateFlow<Int> = _externalSocksPort.asStateFlow()
|
||||
|
||||
var lastBypassApprovalMs: Long = initialApprovalMs
|
||||
|
||||
override suspend fun loadLastBypassApprovalMs(): Long = lastBypassApprovalMs
|
||||
|
||||
override suspend fun saveLastBypassApprovalMs(value: Long) {
|
||||
lastBypassApprovalMs = value
|
||||
}
|
||||
|
||||
fun setTorType(value: TorType) {
|
||||
_torType.value = value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user