diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt new file mode 100644 index 0000000000..fcbc90080c --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/tor/TorBootstrapInstrumentedTest.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index d4c235260c..233155b80c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index daebe61dc4..900cc3e78f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -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 get() = value.torType + override val externalSocksPort: StateFlow 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt new file mode 100644 index 0000000000..3ccf01df15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorBackend.kt @@ -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 + + suspend fun start() + + suspend fun stop() + + suspend fun reset() + + suspend fun resetWithCleanState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index d79fadd94d..b0d750f83a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt new file mode 100644 index 0000000000..3188f15f81 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt @@ -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 + val externalSocksPort: StateFlow + + suspend fun loadLastBypassApprovalMs(): Long + + suspend fun saveLastBypassApprovalMs(value: Long) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 724cc34d59..6b27c59acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -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.Off) - val status: StateFlow = _status.asStateFlow() + override val status: StateFlow = _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() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt new file mode 100644 index 0000000000..8afa86072e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -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.Off) + override val status: StateFlow = _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.asStateFlow() + override val externalSocksPort: StateFlow = _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 + } +}