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:
Claude
2026-05-26 20:31:32 +00:00
parent 9a2adf091d
commit 3517606b81
8 changed files with 738 additions and 27 deletions
@@ -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
@@ -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
}
}