package com.peachpasswords.vault import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test /** In-memory [VaultStorage] so the timer can drive a real repository off-device. */ private class LockTimerFakeStorage : VaultStorage { private var slotA: StoredVault? = null private var slotB: String? = null private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } private var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } /** * Drives [VaultLockTimer] on virtual time. The timer's coroutine scope is replaced * with one backed by the test scheduler, so [advanceTimeBy] moves the idle clock * deterministically without real waiting. */ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class VaultLockTimerTest { private suspend fun unlockedRepo(autoLockMinutes: Int?): VaultRepository { val repo = VaultRepository(LockTimerFakeStorage()) repo.createVault("correct horse battery staple") repo.updateSettings(VaultSettings(autoLockMinutes = autoLockMinutes)) assertTrue(repo.isUnlocked.value) return repo } @Test fun timer_locks_after_configured_delay() = runTest { val repo = unlockedRepo(autoLockMinutes = 1) val timer = VaultLockTimer(repo).also { it.scope = CoroutineScope(StandardTestDispatcher(testScheduler)) } timer.start() advanceTimeBy(59_000); runCurrent() assertTrue("still unlocked before the window elapses", repo.isUnlocked.value) advanceTimeBy(2_000); runCurrent() assertFalse("locked once the idle window elapses", repo.isUnlocked.value) } @Test fun poke_resets_the_countdown() = runTest { val repo = unlockedRepo(autoLockMinutes = 1) val timer = VaultLockTimer(repo).also { it.scope = CoroutineScope(StandardTestDispatcher(testScheduler)) } timer.start() advanceTimeBy(50_000); runCurrent() timer.poke() // 50s in — restart the 60s window advanceTimeBy(50_000); runCurrent() assertTrue("poke pushed the lock past the original deadline", repo.isUnlocked.value) advanceTimeBy(11_000); runCurrent() assertFalse("locks 60s after the last poke", repo.isUnlocked.value) } @Test fun cancel_prevents_firing() = runTest { val repo = unlockedRepo(autoLockMinutes = 1) val timer = VaultLockTimer(repo).also { it.scope = CoroutineScope(StandardTestDispatcher(testScheduler)) } timer.start() timer.cancel() advanceTimeBy(120_000); runCurrent() assertTrue("cancelled timer never locks", repo.isUnlocked.value) } @Test fun null_auto_lock_never_fires() = runTest { val repo = unlockedRepo(autoLockMinutes = null) val timer = VaultLockTimer(repo).also { it.scope = CoroutineScope(StandardTestDispatcher(testScheduler)) } timer.start() advanceTimeBy(3_600_000); runCurrent() assertTrue("null autoLockMinutes means never lock", repo.isUnlocked.value) } }