Merge pull request #504 from JMR-dev/fix-479-seal-state-desync

fix(security): derive key-invalidation and cache-guard decisions from seal state, not the encryptCache setting
This commit was merged in pull request #504.
This commit is contained in:
mergify[bot]
2026-07-10 20:40:22 +00:00
committed by GitHub
11 changed files with 688 additions and 62 deletions
@@ -3,6 +3,9 @@ package org.libremail.data.local
import android.content.Context
import android.content.ContextWrapper
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -15,7 +18,9 @@ import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
@@ -27,7 +32,11 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.security.DatabaseKeyCipher
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.KeystoreCrypto
import org.libremail.data.security.PassphraseSession
import org.libremail.data.security.SealState
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import java.io.File
@@ -72,6 +81,8 @@ class DatabaseProvisionerInstrumentedTest {
clean()
coEvery { keyStore.isClearPending() } returns false
coEvery { keyStore.resolvePassphrase(any()) } returns passphrase
coEvery { keyStore.hasAuthSealedPassphrase() } returns false
coEvery { keyStore.sealWithMaster() } just Runs
coEvery { migrator.migrateIfNeeded() } just Runs
}
@@ -186,6 +197,49 @@ class DatabaseProvisionerInstrumentedTest {
}
}
/**
* Issue #479, on real collaborators end to end: the transitional window — encryptCache already
* OFF, the cache still SQLCipher-encrypted on disk, the passphrase still auth-sealed — must not
* merely decrypt the file; it must also RELEASE the orphaned auth seal by resealing under the
* non-auth master key. Otherwise the app stays in the window indefinitely, where losing the
* auth-bound key (device-lock removal / re-enrollment) forces a needless cache wipe.
*
* Uses a REAL [DatabaseKeyStore] over a fresh test DataStore plus real Keystore crypto: the
* sealed-auth blob only needs to EXIST (sealState reads presence), and the passphrase itself
* comes from the unlocked [PassphraseSession] — exactly the state after a real authentication.
*/
@Test
fun encryptionTurnedOffReleasesTheOrphanedAuthSealAfterDecrypting() = runBlocking<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = true))
seedPlaintextRow()
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
assertTrue("precondition: the cache starts encrypted", DatabaseEncryption.isEncrypted(dbFile))
val session = PassphraseSession().apply { unlock(passphrase) }
val realKeyStore = DatabaseKeyStore(appContext, KeystoreCrypto(), DatabaseKeyCipher(), session)
realKeyStore.dataStore = PreferenceDataStoreFactory.create(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
) { File(appContext.cacheDir, "provisioner_seal_test_${System.nanoTime()}.preferences_pb") }
realKeyStore.dataStore.edit { it[stringPreferencesKey("sealed_db_key_auth")] = "sealed-by-auth-key" }
assertTrue("precondition: the passphrase is auth-sealed", realKeyStore.hasAuthSealedPassphrase())
val provisioner = DatabaseProvisioner(context, realKeyStore, settingsRepository, migrator, Dispatchers.IO)
val mode = provisioner.prepareCache()
assertEquals(CacheOpenMode.Plaintext, mode)
assertFalse("the cache was decrypted", DatabaseEncryption.isEncrypted(dbFile))
// The headline: no orphaned auth seal survives the conversion — it was resealed under the
// master key, so the passphrase stays recoverable WITHOUT authentication...
assertFalse("the auth seal must be released", realKeyStore.hasAuthSealedPassphrase())
assertEquals(SealState.MASTER, realKeyStore.sealState())
// ...and a later encryptCache re-enable reuses the very same passphrase (real Keystore round-trip).
assertEquals(passphrase, realKeyStore.passphrase())
openPlaintext().apply {
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
close()
}
}
@Test
fun plaintextStartLeavesThePlaintextCacheUntouched() = runBlocking<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
@@ -9,6 +9,7 @@ import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
@@ -22,17 +23,21 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.data.security.PassphraseSession
import org.libremail.data.security.SealState
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import java.io.File
/**
* On-device proof that [PruneWorker] and [BackfillWorker] defer (`Result.retry()`) while the encrypted
* cache is locked, driving them with the REAL [EncryptedCacheGuard] rather than the mocked guard the JVM
* `PruneWorkerTest`/`BackfillWorkerTest` use (issue #225). [EncryptedCacheGuard] depends only on
* [SettingsRepository] and [PassphraseSession] — never the Keystore or `BiometricPrompt` — so the locked
* state is reproduced here with no device auth: a fixed [SettingsRepository] (mocked the same way
* [SettingsRepository], [DatabaseKeyStore] (its seal state — issue #479), a raw header read of the
* cache file, and [PassphraseSession] — never the Keystore crypto or `BiometricPrompt` — so the locked
* state is reproduced here with no device auth: fixed settings/seal collaborators (mocked the same way
* `DatabaseProvisionerInstrumentedTest` fakes its security/settings collaborators) plus a real,
* never-unlocked [PassphraseSession].
*
@@ -120,12 +125,98 @@ class WorkerCacheLockDeferralInstrumentedTest {
assertFalse(cacheGuard.isCacheLocked())
}
/** A real [EncryptedCacheGuard] over a fixed (mocked) [SettingsRepository] and the real [session]. */
private fun guardFor(appLock: Boolean, encryptCache: Boolean): EncryptedCacheGuard {
// --- Issue #479: the guard answers from the seal + on-disk state, not the settings pair -------
/**
* Transitional case A: encryptCache was toggled OFF but the on-disk decrypt runs only at the next
* cold start — the cache is still auth-sealed and encrypted. The settings-derived formula said
* "unlocked" here, so a worker proceeded into `provideDatabase` and parked forever on the
* passphrase await (holding the provisioner mutex). The real guard must report locked so the
* worker defers with `Result.retry()` instead.
*/
@Test
fun pruneWorkerDefersDuringTheEncryptCacheOffTransitionalWindow() = runBlocking<Unit> {
val cacheGuard = guardFor(
appLock = true,
encryptCache = false,
sealState = SealState.AUTH,
dbFile = encryptedFixtureFile(),
)
assertTrue(
"precondition: auth-sealed + still-encrypted must report locked despite the off setting",
cacheGuard.isCacheLocked(),
)
val lazyPruner = mockk<Lazy<MailPruner>>()
val worker = TestListenableWorkerBuilder<PruneWorker>(context)
.setWorkerFactory(pruneWorkerFactory(lazyPruner, cacheGuard))
.build()
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
assertEquals(Result.retry(), result)
verify(exactly = 0) { lazyPruner.get() }
}
/**
* Transitional case B: app-lock (or encryptCache) was just enabled mid-session, but the passphrase
* is still MASTER-sealed until the next authentication reseals it — the DB opens auth-free, so
* background sync/push/send must NOT stall on the settings pair (the old formula reported locked).
*/
@Test
fun theRealGuardReportsUnlockedWhileThePassphraseIsStillMasterSealed() = runBlocking<Unit> {
val cacheGuard = guardFor(appLock = true, encryptCache = true, sealState = SealState.MASTER)
assertFalse(cacheGuard.isCacheLocked())
}
/**
* A lingering (orphaned) auth seal over an already-plaintext cache — the post-conversion tail of
* case A on installs that predate the provisioner's reseal — must not stall background work: the
* plaintext open needs no passphrase.
*/
@Test
fun theRealGuardReportsUnlockedOnceTheCacheIsPlaintextDespiteALingeringAuthSeal() = runBlocking<Unit> {
val cacheGuard = guardFor(
appLock = true,
encryptCache = false,
sealState = SealState.AUTH,
dbFile = plaintextFixtureFile(),
)
assertFalse(cacheGuard.isCacheLocked())
}
/**
* A real [EncryptedCacheGuard] over fixed (mocked) [SettingsRepository]/[DatabaseKeyStore]
* collaborators, the real [session], and — for the [SealState.AUTH]-with-setting-off transitional
* window — a real fixture file whose header decides the on-disk half of the answer.
*/
private fun guardFor(
appLock: Boolean,
encryptCache: Boolean,
sealState: SealState = SealState.NONE,
dbFile: File? = null,
): EncryptedCacheGuard {
val settingsRepository = mockk<SettingsRepository>()
val settings = AppSettings(appLock = appLock, encryptCache = encryptCache)
every { settingsRepository.settings } returns flowOf(settings)
return EncryptedCacheGuard(settingsRepository, session)
val keyStore = mockk<DatabaseKeyStore>()
coEvery { keyStore.sealState() } returns sealState
return EncryptedCacheGuard(context, settingsRepository, keyStore, session)
.apply { if (dbFile != null) this.dbFile = dbFile }
}
/** A file whose header is NOT the SQLite magic — what a SQLCipher-encrypted cache looks like. */
private fun encryptedFixtureFile(): File = File.createTempFile("guard_encrypted", ".db", context.cacheDir).apply {
deleteOnExit()
writeBytes(ByteArray(48) { 0x5A })
}
/** A file with a genuine plaintext-SQLite 16-byte header (padded past it). */
private fun plaintextFixtureFile(): File = File.createTempFile("guard_plaintext", ".db", context.cacheDir).apply {
deleteOnExit()
writeBytes("SQLite format 3".toByteArray(Charsets.US_ASCII) + byteArrayOf(0) + ByteArray(32))
}
private fun pruneWorkerFactory(lazyPruner: Lazy<MailPruner>, cacheGuard: EncryptedCacheGuard) =
@@ -27,8 +27,10 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.data.security.PassphraseSession
import org.libremail.data.security.SealState
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.BackfillPacer
@@ -162,11 +164,15 @@ class FetchGateReceiverInstrumentedTest {
return requireNotNull(readBack[0]) { "receiver set no result data" }
}
/** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off) — so only the gate can defer. */
/** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off, no seal) — so only the gate can defer. */
private fun unlockedGuard(): EncryptedCacheGuard {
val settingsRepository = mockk<SettingsRepository>()
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = false, encryptCache = true))
return EncryptedCacheGuard(settingsRepository, session)
// No seal exists (issue #479: the guard answers from the seal state, not the settings pair),
// so the un-armed cache never blocks and only the fetch gate can defer the worker.
val keyStore = mockk<DatabaseKeyStore>()
coEvery { keyStore.sealState() } returns SealState.NONE
return EncryptedCacheGuard(context, settingsRepository, keyStore, session)
}
private fun backfillWorkerFactory(lazyBackfiller: Lazy<MailBackfiller>, cacheGuard: EncryptedCacheGuard) =
@@ -3,6 +3,7 @@ package org.libremail.data.local
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@@ -183,6 +184,7 @@ class DatabaseProvisioner internal constructor(
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
releaseOrphanedAuthSeal()
CacheOpenMode.Plaintext
}
@@ -190,6 +192,29 @@ class DatabaseProvisioner internal constructor(
}
}
/**
* The cache is plaintext now, so a passphrase still sealed by the auth-bound key is an orphan:
* nothing needs it to open the database, but its presence keeps the app in the issue-#479
* transitional state ([org.libremail.data.security.SealState.AUTH] with `encryptCache` off) —
* where losing the auth key (device-lock removal / re-enrollment) forces a needless cache wipe.
* Reseal it under the non-auth master key (dropping the auth seal and its Keystore key) so the
* passphrase stays recoverable and a later `encryptCache` re-enable reuses it seamlessly.
* Best-effort: the session holds the just-used passphrase on this path, but if the reseal still
* fails the plaintext open must proceed — the lingering seal is handled defensively everywhere
* (EncryptedCacheGuard checks the file, the key-invalidation policy treats it as protected).
*/
private suspend fun releaseOrphanedAuthSeal() {
if (!keyStore.hasAuthSealedPassphrase()) return
try {
keyStore.sealWithMaster()
AppLog.i(TAG, "released orphaned auth seal after decrypt-on-disable (resealed under master key)")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLog.w(TAG, "failed to release orphaned auth seal after decrypt-on-disable", e)
}
}
private companion object {
const val TAG = "DatabaseProvisioner"
}
@@ -1,31 +1,79 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.content.Context
import androidx.annotation.VisibleForTesting
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.settings.SettingsRepository
import org.libremail.reporting.AppLog
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tells UI-less entry points (background sync/push/send) whether opening the Room cache would block
* on the user authenticating. When app-lock AND encrypted-cache are on, the SQLCipher passphrase is
* sealed by an auth-bound key and only lives in [PassphraseSession] after an unlock — so a worker
* that constructs a DAO before then would park a thread indefinitely inside `provideDatabase`.
* on the user authenticating — a worker that constructs a DAO while it would must defer
* (`Result.retry()`) instead of parking a thread indefinitely inside `provideDatabase`.
*
* Depends only on DataStore ([SettingsRepository]) and in-memory state ([PassphraseSession]) — never
* on the Room database — so it is safe to consult *before* touching any DB-backed dependency.
* The answer mirrors [DatabaseKeyStore.resolvePassphrase]'s blocking branches: it is keyed off which
* seal actually EXISTS ([DatabaseKeyStore.sealState]), never off the appLock/encryptCache settings
* alone, which live in a separate DataStore and can lag the on-disk truth (issue #479):
* - [SealState.AUTH]: the passphrase only becomes available after the user authenticates, so the
* cache is locked while the session is — INCLUDING the transitional window where `encryptCache`
* was already toggled off but the deferred decrypt-to-plaintext (next cold start) hasn't run yet.
* Once the file is already plaintext an open no longer needs the passphrase, so a lingering
* (orphaned) auth seal alone does not lock it.
* - [SealState.MASTER]: the passphrase auto-unwraps without authentication — never locked (e.g.
* app-lock was just enabled mid-session and the reseal under the auth key happens only at the
* next authentication; stalling sync/push/send until then would be needless).
* - [SealState.NONE]: locked only while both settings ask for an auth-armed cache that hasn't been
* armed yet (the first-time arm happens at the next authentication).
*
* Depends only on DataStore ([SettingsRepository], [DatabaseKeyStore]), in-memory state
* ([PassphraseSession]) and a raw 16-byte header read of the cache file — never on the Room
* database — so it is safe to consult *before* touching any DB-backed dependency.
*/
@Singleton
class EncryptedCacheGuard @Inject constructor(
@ApplicationContext context: Context,
private val settingsRepository: SettingsRepository,
private val keyStore: DatabaseKeyStore,
private val session: PassphraseSession,
) {
/**
* The on-disk cache file whose header decides the transitional [SealState.AUTH] case. A
* [VisibleForTesting] seam (mirroring [DatabaseKeyStore.dataStore]) so tests can point the guard
* at a fixture file instead of the app's real cache.
*/
@VisibleForTesting
internal var dbFile: File = context.getDatabasePath(DatabaseFiles.NAME)
/**
* True when the encrypted cache is currently locked, i.e. opening the database would suspend
* waiting for the user to authenticate. Background work should defer (e.g. `Result.retry()`).
*/
suspend fun isCacheLocked(): Boolean {
if (session.isUnlocked()) return false
val settings = settingsRepository.settings.first()
return settings.appLock && settings.encryptCache && !session.isUnlocked()
return when (keyStore.sealState()) {
SealState.MASTER -> false
SealState.NONE -> settings.appLock && settings.encryptCache
SealState.AUTH -> {
val locked = settings.encryptCache || DatabaseEncryption.isEncrypted(dbFile)
if (!settings.encryptCache) {
// The transitional window (issue #479): the setting is already off but the
// auth seal still exists. Breadcrumb the divergence — booleans only, no PII.
AppLog.i(TAG, "auth seal present with encryptCache off (transitional); cacheLocked=$locked")
}
locked
}
}
}
private companion object {
const val TAG = "LibreMailCacheGuard"
}
}
@@ -17,8 +17,9 @@ enum class LockAction {
CLEAR_AND_REQUIRE_AUTH,
/**
* The device no longer has a secure lock and there is nothing encrypted to protect: silently
* disable app-lock (there is no key to authenticate against) and proceed.
* The device no longer has a secure lock and there is nothing encrypted to protect — the
* encrypted-cache setting is off AND no auth-sealed passphrase exists: silently disable
* app-lock (there is no key to authenticate against) and proceed.
*/
DISABLE_APP_LOCK,
@@ -31,22 +32,32 @@ enum class LockAction {
}
/**
* Pure decision table reconciling the app-lock / encrypted-cache settings with the current device
* Pure decision table reconciling the app-lock / encrypted-cache state with the current device
* security state and Keystore key validity. Extracted from any Android or crypto dependency so the
* (security-critical) branch logic — in particular the "clear + re-sync, never corrupt" handling of
* lock removal and biometric re-enrollment — is fully unit-tested.
*/
object KeyInvalidationPolicy {
/**
* [encryptedCacheProtected] must reflect the ACTUAL protection state, not the `encryptCache`
* setting alone: the setting is written immediately but the on-disk conversion is deferred to
* the next cold start, so an auth-sealed (still encrypted) cache can outlive the setting being
* off. Callers derive it as `encryptCache setting || an auth-sealed passphrase exists`
* (issue #479); feeding the raw setting here silently disabled app-lock during that
* transitional window and stranded a permanently unreadable cache.
*/
fun decide(
appLockEnabled: Boolean,
encryptCacheEnabled: Boolean,
encryptedCacheProtected: Boolean,
deviceSecure: Boolean,
keyInvalidated: Boolean,
): LockAction = when {
!appLockEnabled -> LockAction.PROCEED
!deviceSecure -> if (encryptCacheEnabled) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK
keyInvalidated -> if (encryptCacheEnabled) LockAction.CLEAR_AND_REQUIRE_AUTH else LockAction.REQUIRE_AUTH
!deviceSecure ->
if (encryptedCacheProtected) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK
keyInvalidated ->
if (encryptedCacheProtected) LockAction.CLEAR_AND_REQUIRE_AUTH else LockAction.REQUIRE_AUTH
else -> LockAction.REQUIRE_AUTH
}
}
@@ -142,9 +142,19 @@ class AppLockViewModel @Inject constructor(
// renders before the (async) decision lands.
if (_uiState.value == AppLockUiState.Unlocked) _uiState.value = AppLockUiState.Checking
val action = withContext(defaultDispatcher) {
// Key the decision off the ACTUAL protection state, not the encryptCache setting
// alone: the setting flips immediately, but the on-disk decrypt runs at the next
// cold start, so an auth-sealed (still encrypted) cache can outlive
// `encryptCache == false`. Losing the auth key in that window must CLEAR (wipe +
// re-sync), never silently disable the lock and strand the seal — the same
// gate-on-the-seal fix SettingsViewModel.setAppLock already carries (issue #479).
val authSealed = databaseKeyStore.hasAuthSealedPassphrase()
if (authSealed && !settings.encryptCache) {
AppLog.i(TAG, "encryptCache off but passphrase still auth-sealed; treating cache as protected")
}
KeyInvalidationPolicy.decide(
appLockEnabled = true,
encryptCacheEnabled = settings.encryptCache,
encryptedCacheProtected = settings.encryptCache || authSealed,
deviceSecure = appLockManager.isDeviceSecure(),
keyInvalidated = databaseKeyCipher.isInvalidated(),
)
@@ -155,6 +165,9 @@ class AppLockViewModel @Inject constructor(
LockAction.PROCEED -> _uiState.value = AppLockUiState.Unlocked
LockAction.DISABLE_APP_LOCK -> {
// Only reachable when NO auth-sealed passphrase exists (encryptedCacheProtected
// above ORs the seal in), so dropping the gate here can never orphan a seal or
// strand a still-encrypted cache — those states land on CLEAR_AND_DISABLE.
settingsRepository.setAppLock(false)
_uiState.value = AppLockUiState.Unlocked
}
@@ -64,6 +64,7 @@ class DatabaseProvisionerTest {
// detekt-forbidden, epic #324) so it does not throw "not mocked".
mockkStatic(android.util.Log::class)
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
every { android.util.Log.i(any(), any()) } returns 0
every { context.getDatabasePath(any()) } returns File("libremail.db")
every { DatabaseFiles.clear(any()) } just Runs
@@ -78,6 +79,8 @@ class DatabaseProvisionerTest {
coEvery { keyStore.resetSealedPassphrase() } just Runs
coEvery { keyStore.clearClearPending() } just Runs
coEvery { keyStore.resolvePassphrase(any()) } returns PASSPHRASE
coEvery { keyStore.hasAuthSealedPassphrase() } returns false
coEvery { keyStore.sealWithMaster() } just Runs
coEvery { accountDataMigrator.migrateIfNeeded() } just Runs
}
@@ -278,6 +281,46 @@ class DatabaseProvisionerTest {
assertEquals(CacheOpenMode.Plaintext, mode)
verify(exactly = 1) { DatabaseEncryption.ensurePlaintext(any(), PASSPHRASE) }
verify(exactly = 0) { DatabaseEncryption.ensureEncrypted(any(), any()) }
// No auth seal exists (the passphrase was master-sealed), so nothing must be resealed.
coVerify(exactly = 0) { keyStore.sealWithMaster() }
}
@Test
fun `decrypt-on-disable releases a lingering auth seal by resealing under the master key`() = runTest {
// Issue #479: after the decrypt-to-plaintext conversion the auth seal is an orphan — nothing
// needs it to open the DB, but its presence keeps the app in the transitional window where
// losing the auth-bound key forces a needless cache wipe. It must be resealed under the
// master key AFTER the file conversion (the passphrase is still in hand on this path).
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = true))
every { DatabaseEncryption.isEncrypted(any()) } returns true
coEvery { keyStore.hasAuthSealedPassphrase() } returns true
val mode = provisioner().prepareCache()
assertEquals(CacheOpenMode.Plaintext, mode)
coVerifyOrder {
keyStore.resolvePassphrase(true)
DatabaseEncryption.ensurePlaintext(any(), PASSPHRASE)
keyStore.sealWithMaster()
}
}
@Test
fun `a failed reseal after decrypt-on-disable is non-fatal and still opens plaintext`() = runTest {
// The reseal is best-effort: the cache is already plaintext, so a Keystore hiccup must not
// fail the open (the lingering seal is handled defensively by the guard and the policy).
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = true))
every { DatabaseEncryption.isEncrypted(any()) } returns true
coEvery { keyStore.hasAuthSealedPassphrase() } returns true
coEvery { keyStore.sealWithMaster() } throws IllegalStateException("keystore busy")
val mode = provisioner().prepareCache()
assertEquals(CacheOpenMode.Plaintext, mode)
coVerify(exactly = 1) { keyStore.sealWithMaster() }
// Fail soft, never destructive: the seal is left alone rather than reset/wiped.
coVerify(exactly = 0) { keyStore.resetSealedPassphrase() }
verify(exactly = 0) { DatabaseFiles.clear(any()) }
}
@Test
@@ -1,57 +1,172 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.content.Context
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import java.io.File
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [EncryptedCacheGuard.isCacheLocked] is the pure truth table `appLock && encryptCache && !unlocked`:
* only when app-lock AND the encrypted cache are both on AND the passphrase session has not been
* unlocked would opening the Room DB block on authentication, so background work must defer. Every
* other combination is safe to proceed. Both collaborators are DataStore/in-memory only, so this is a
* clean JVM test.
* [EncryptedCacheGuard.isCacheLocked] mirrors [DatabaseKeyStore.resolvePassphrase]'s blocking
* branches: locked iff opening the Room DB would actually suspend on the user authenticating. The
* decision is keyed off which seal EXISTS ([DatabaseKeyStore.sealState]) plus — in the transitional
* [SealState.AUTH]-with-encryptCache-off window (issue #479) — whether the on-disk file is still
* encrypted. Deriving it from the appLock/encryptCache settings alone answered wrongly in both
* transitional states: workers parked forever inside `provideDatabase` when the setting was off but
* the DB still auth-sealed, and sync/push/send stalled needlessly while the seal was still MASTER.
*
* The file-header check runs against real temp files (a genuine 16-byte SQLite header vs. junk
* bytes), so [org.libremail.data.local.DatabaseEncryption.isEncrypted] is exercised for real.
*/
class EncryptedCacheGuardTest {
private val settingsRepository = mockk<SettingsRepository>()
private val session = mockk<PassphraseSession>()
@get:Rule
val tmp = TemporaryFolder()
private suspend fun cacheLocked(appLock: Boolean, encryptCache: Boolean, unlocked: Boolean): Boolean {
private val settingsRepository = mockk<SettingsRepository>()
private val keyStore = mockk<DatabaseKeyStore>()
private val session = mockk<PassphraseSession>()
private val context = mockk<Context>()
@Before
fun setUp() {
// android.util.Log is a no-op stub that throws "not mocked" in JVM tests; the guard
// breadcrumbs the transitional divergence through AppLog, which always forwards to it.
mockkStatic(android.util.Log::class)
every { android.util.Log.i(any(), any()) } returns 0
}
@After
fun tearDown() {
unmockkAll()
}
private suspend fun cacheLocked(
appLock: Boolean = true,
encryptCache: Boolean = true,
unlocked: Boolean = false,
sealState: SealState = SealState.NONE,
dbFile: File = plaintextDbFile(),
): Boolean {
every { settingsRepository.settings } returns
flowOf(AppSettings(appLock = appLock, encryptCache = encryptCache))
every { session.isUnlocked() } returns unlocked
return EncryptedCacheGuard(settingsRepository, session).isCacheLocked()
coEvery { keyStore.sealState() } returns sealState
every { context.getDatabasePath(any()) } returns dbFile
return EncryptedCacheGuard(context, settingsRepository, keyStore, session).isCacheLocked()
}
/** A file with a genuine plaintext-SQLite 16-byte header (padded past it). */
private fun plaintextDbFile(): File = tmp.newFile().apply {
writeBytes("SQLite format 3".toByteArray(Charsets.US_ASCII) + byteArrayOf(0) + ByteArray(32))
}
/** A file whose header is NOT the SQLite magic — what a SQLCipher-encrypted cache looks like. */
private fun encryptedDbFile(): File = tmp.newFile().apply {
writeBytes(ByteArray(48) { 0x5A })
}
// --- SealState.NONE: the original settings-driven rows are preserved -------------------------
@Test
fun `no seal - locked only while both settings ask for an auth-armed cache`() = runTest {
// First-time arm pending: resolvePassphrase(appLock=true) would await the session.
assertTrue(cacheLocked(appLock = true, encryptCache = true, sealState = SealState.NONE))
}
@Test
fun `locked only when app-lock and encrypted cache are on and the session is not unlocked`() = runTest {
assertTrue(cacheLocked(appLock = true, encryptCache = true, unlocked = false))
fun `no seal - not locked once the passphrase session is unlocked`() = runTest {
assertFalse(cacheLocked(appLock = true, encryptCache = true, unlocked = true, sealState = SealState.NONE))
}
@Test
fun `not locked once the passphrase session is unlocked`() = runTest {
assertFalse(cacheLocked(appLock = true, encryptCache = true, unlocked = true))
fun `no seal - not locked when the cache is not encrypted`() = runTest {
assertFalse(cacheLocked(appLock = true, encryptCache = false, sealState = SealState.NONE))
}
@Test
fun `not locked when the cache is not encrypted`() = runTest {
assertFalse(cacheLocked(appLock = true, encryptCache = false, unlocked = false))
fun `no seal - not locked when app-lock is off`() = runTest {
assertFalse(cacheLocked(appLock = false, encryptCache = true, sealState = SealState.NONE))
}
// --- SealState.MASTER: auto-unwraps without authentication — never locked --------------------
@Test
fun `master seal - not locked even with both settings on and the session locked`() = runTest {
// Issue #479 (transitional case B): app-lock/encryptCache just enabled mid-session; the
// passphrase is still master-sealed until the next authentication reseals it, so the DB
// opens auth-free — background sync/push/send must NOT stall on the settings pair.
assertFalse(cacheLocked(appLock = true, encryptCache = true, sealState = SealState.MASTER))
}
// --- SealState.AUTH: locked while the session is, including the setting-off window -----------
@Test
fun `auth seal - locked while the setting is on and the session is locked`() = runTest {
assertTrue(cacheLocked(appLock = true, encryptCache = true, sealState = SealState.AUTH))
}
@Test
fun `not locked when app-lock is off`() = runTest {
assertFalse(cacheLocked(appLock = false, encryptCache = true, unlocked = false))
fun `auth seal - not locked once the session is unlocked`() = runTest {
assertFalse(cacheLocked(appLock = true, encryptCache = true, unlocked = true, sealState = SealState.AUTH))
}
@Test
fun `not locked when neither app-lock nor encrypted cache is on`() = runTest {
assertFalse(cacheLocked(appLock = false, encryptCache = false, unlocked = true))
fun `auth seal - locked when the setting is off but the cache file is still encrypted`() = runTest {
// Issue #479 (transitional case A): encryptCache was toggled off but the decrypt runs only
// at the next cold start. Opening now would suspend in resolvePassphrase on session.await(),
// so the guard must report locked — the old settings-derived answer said "unlocked" and let
// workers park forever inside provideDatabase.
assertTrue(
cacheLocked(
appLock = true,
encryptCache = false,
sealState = SealState.AUTH,
dbFile = encryptedDbFile(),
),
)
}
@Test
fun `auth seal - not locked when the setting is off and the cache file is already plaintext`() = runTest {
// A lingering (orphaned) auth seal after the decrypt-on-disable conversion: the plaintext
// open needs no passphrase, so background work must not be stalled by the leftover seal.
assertFalse(
cacheLocked(
appLock = true,
encryptCache = false,
sealState = SealState.AUTH,
dbFile = plaintextDbFile(),
),
)
}
@Test
fun `auth seal - locked even when app-lock is off while the file is still encrypted`() = runTest {
// The desync state the old formula wedged on: app-lock already off but the cache is still
// auth-sealed and encrypted. resolvePassphrase keys off the seal, so an open WOULD suspend;
// deferring (retry) is the only safe answer.
assertTrue(
cacheLocked(
appLock = false,
encryptCache = false,
sealState = SealState.AUTH,
dbFile = encryptedDbFile(),
),
)
}
}
@@ -6,12 +6,15 @@ import kotlin.test.assertEquals
class KeyInvalidationPolicyTest {
// `cacheProtected` is decide()'s `encryptedCacheProtected` input: derived by callers as
// "encryptCache setting ON, or an auth-sealed passphrase still exists" (issue #479) — never the
// raw setting, which can already be off while the on-disk cache is still auth-sealed.
private fun decide(
appLock: Boolean = true,
encrypt: Boolean = true,
cacheProtected: Boolean = true,
secure: Boolean = true,
invalidated: Boolean = false,
) = KeyInvalidationPolicy.decide(appLock, encrypt, secure, invalidated)
) = KeyInvalidationPolicy.decide(appLock, cacheProtected, secure, invalidated)
@Test
fun `app-lock off proceeds`() {
@@ -25,41 +28,53 @@ class KeyInvalidationPolicyTest {
}
@Test
fun `lock removed with encrypted cache clears and disables`() {
fun `lock removed with a protected cache clears and disables`() {
// The auth-bound passphrase is unrecoverable; wipe the cache and drop the gate.
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, encrypt = true))
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, cacheProtected = true))
}
@Test
fun `lock removed without encrypted cache just disables`() {
assertEquals(LockAction.DISABLE_APP_LOCK, decide(secure = false, encrypt = false))
fun `lock removed while the cache is still auth-sealed clears even with the setting off`() {
// The issue-#479 transitional window: encryptCache was just toggled OFF (setting written,
// on-disk decrypt deferred to the next cold start, SEALED_AUTH still present) and the user
// removes the device lock. The caller derives cacheProtected from setting-OR-seal, so this
// state lands on the same protected row — CLEAR_AND_DISABLE — never DISABLE_APP_LOCK, which
// would strand a permanently unreadable cache behind a gate that no longer authenticates.
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, cacheProtected = true, invalidated = true))
}
@Test
fun `biometric re-enrollment with encrypted cache clears and re-requires auth`() {
assertEquals(LockAction.CLEAR_AND_REQUIRE_AUTH, decide(invalidated = true, encrypt = true))
fun `lock removed without a protected cache just disables`() {
assertEquals(LockAction.DISABLE_APP_LOCK, decide(secure = false, cacheProtected = false))
}
@Test
fun `biometric re-enrollment without encrypted cache re-requires auth without clearing`() {
fun `biometric re-enrollment with a protected cache clears and re-requires auth`() {
assertEquals(LockAction.CLEAR_AND_REQUIRE_AUTH, decide(invalidated = true, cacheProtected = true))
}
@Test
fun `biometric re-enrollment without a protected cache re-requires auth without clearing`() {
// Nothing encrypted to lose; a fresh key is minted on the next successful unlock.
assertEquals(LockAction.REQUIRE_AUTH, decide(invalidated = true, encrypt = false))
assertEquals(LockAction.REQUIRE_AUTH, decide(invalidated = true, cacheProtected = false))
}
@Test
fun `lock removal takes precedence over key-invalidation flag`() {
// Both true: the device being insecure dominates (can't authenticate at all).
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, invalidated = true, encrypt = true))
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, invalidated = true, cacheProtected = true))
}
@Test
fun `every one of the 16 input combinations maps to its pinned action`() {
// The complete truth table for decide(appLock, encrypt, secure, invalidated): all 2^4 = 16 rows
// listed explicitly, so a mutation of ANY branch is caught — most importantly the common
// (on, *, secure, valid) rows, whose silent flip to PROCEED would be a lock bypass. The
// completeness guard below fails if a row is ever dropped, keeping the table exhaustive.
// The complete truth table for decide(appLock, cacheProtected, secure, invalidated): all
// 2^4 = 16 rows listed explicitly, so a mutation of ANY branch is caught — most importantly
// the common (on, *, secure, valid) rows, whose silent flip to PROCEED would be a lock
// bypass. The completeness guard below fails if a row is ever dropped, keeping the table
// exhaustive. `cacheProtected` is setting-OR-seal (issue #479), so the "protected" rows also
// pin the transitional encryptCache-off-but-still-auth-sealed states.
//
// Columns: appLock, encrypt, secure, invalidated -> expected action.
// Columns: appLock, cacheProtected, secure, invalidated -> expected action.
val table = listOf(
// App-lock OFF: always PROCEED, whatever the other three inputs are.
Case(false, false, false, false, LockAction.PROCEED),
@@ -70,12 +85,13 @@ class KeyInvalidationPolicyTest {
Case(false, true, false, true, LockAction.PROCEED),
Case(false, true, true, false, LockAction.PROCEED),
Case(false, true, true, true, LockAction.PROCEED),
// App-lock ON, device NOT secure (lock removed): clear+disable iff a cache exists, else disable.
// App-lock ON, device NOT secure (lock removed): clear+disable iff a cache is protected
// (setting on OR still auth-sealed), else disable.
Case(true, true, false, false, LockAction.CLEAR_AND_DISABLE),
Case(true, true, false, true, LockAction.CLEAR_AND_DISABLE),
Case(true, false, false, false, LockAction.DISABLE_APP_LOCK),
Case(true, false, false, true, LockAction.DISABLE_APP_LOCK),
// App-lock ON, secure, key invalidated: clear+re-auth iff a cache exists, else just re-auth.
// App-lock ON, secure, key invalidated: clear+re-auth iff a cache is protected, else just re-auth.
Case(true, true, true, true, LockAction.CLEAR_AND_REQUIRE_AUTH),
Case(true, false, true, true, LockAction.REQUIRE_AUTH),
// App-lock ON, secure, key valid: the common case — require auth, no wipe.
@@ -83,19 +99,19 @@ class KeyInvalidationPolicyTest {
Case(true, false, true, false, LockAction.REQUIRE_AUTH),
)
// Exhaustiveness: exactly the 16 distinct (appLock, encrypt, secure, invalidated) combinations.
// Exhaustiveness: exactly the 16 distinct (appLock, cacheProtected, secure, invalidated) combinations.
assertEquals(16, table.size, "the table must list all 2^4 input combinations")
assertEquals(
16,
table.map { listOf(it.appLock, it.encrypt, it.secure, it.invalidated) }.toSet().size,
table.map { listOf(it.appLock, it.cacheProtected, it.secure, it.invalidated) }.toSet().size,
"every row must be a distinct input combination",
)
for (case in table) {
assertEquals(
case.expected,
decide(case.appLock, case.encrypt, case.secure, case.invalidated),
"decide(appLock=${case.appLock}, encrypt=${case.encrypt}, " +
decide(case.appLock, case.cacheProtected, case.secure, case.invalidated),
"decide(appLock=${case.appLock}, cacheProtected=${case.cacheProtected}, " +
"secure=${case.secure}, invalidated=${case.invalidated})",
)
}
@@ -103,7 +119,7 @@ class KeyInvalidationPolicyTest {
private data class Case(
val appLock: Boolean,
val encrypt: Boolean,
val cacheProtected: Boolean,
val secure: Boolean,
val invalidated: Boolean,
val expected: LockAction,
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.lock
import android.content.Context
import android.os.SystemClock
import androidx.work.Operation
import com.google.common.util.concurrent.ListenableFuture
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.security.AppLockGate
import org.libremail.data.security.AppLockManager
import org.libremail.data.security.DatabaseKeyCipher
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.LockState
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import org.libremail.restart.ProcessRestarter
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* The issue-#479 seal-present/setting-off matrix: `onForeground` must derive
* [org.libremail.data.security.KeyInvalidationPolicy]'s `encryptedCacheProtected` input from the
* ACTUAL protection state (`encryptCache setting || hasAuthSealedPassphrase()`), never the setting
* alone. The setting flips immediately, but the on-disk decrypt runs only at the next cold start, so
* an auth-sealed (still encrypted) cache can outlive `encryptCache == false` — the transitional
* window in which the old raw-setting input silently chose DISABLE_APP_LOCK on device-lock removal,
* orphaned SEALED_AUTH without a wipe, and bricked every later launch inside
* `DatabaseKeyStore.resolvePassphrase` (a `session.await()` nothing could ever complete).
*
* Unlike `AppLockViewModelTest`'s LockAction-dispatch tests (which stub the policy object), these
* drive the REAL [org.libremail.data.security.KeyInvalidationPolicy] through the ViewModel so the
* derivation itself — not just the dispatch — is pinned. Split into its own class (rather than grown
* onto AppLockViewModelTest) to respect detekt's LargeClass budget there.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AppLockViewModelSealStateTest {
private val dispatcher = UnconfinedTestDispatcher()
private val logBuffer = RingLogBuffer()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
// android.util.Log is a throwing stub under plain JVM tests and AppLog always forwards to
// it; stub the levels these paths breadcrumb through, then install a real buffer so the
// breadcrumbs are asserted for real (never `verify { Log... }`) — mirroring AppLockViewModelTest.
mockkStatic(android.util.Log::class)
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
AppLog.install(logBuffer)
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns FOREGROUND_AT
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `lock removal during the encryptCache-off transitional window clears and disables`() = runTest(dispatcher) {
val fixture = fixture(deviceSecure = false, authSealed = true)
fixture.vm.onForeground()
advanceUntilIdle()
// The setting is off but SEALED_AUTH still exists, so the policy must land on
// CLEAR_AND_DISABLE: wipe scheduled, gate dropped, process restarted — never the old
// DISABLE_APP_LOCK, which orphaned the seal without a wipe and hung every later launch
// forever inside DatabaseKeyStore.resolvePassphrase (the app bricked behind the cache gate).
coVerifyOrder {
fixture.databaseKeyStore.setClearPending()
fixture.settingsRepository.setAppLock(false)
fixture.processRestarter.restart()
}
// Issue #479: the seal-overrides-setting derivation and the decision are both breadcrumbed.
val messages = logBuffer.snapshot().map { it.message }
assertTrue("encryptCache off but passphrase still auth-sealed; treating cache as protected" in messages)
assertTrue("app-lock foreground decision: CLEAR_AND_DISABLE" in messages)
}
@Test
fun `lock removal with no auth seal and the setting off disables without clearing`() = runTest(dispatcher) {
val fixture = fixture(deviceSecure = false, authSealed = false)
fixture.vm.onForeground()
advanceUntilIdle()
// Nothing encrypted and nothing sealed: still DISABLE_APP_LOCK — the seal-derived input
// must not turn every lock removal into a destructive wipe.
coVerify { fixture.settingsRepository.setAppLock(false) }
coVerify(exactly = 0) { fixture.databaseKeyStore.setClearPending() }
verify(exactly = 0) { fixture.processRestarter.restart() }
assertIs<AppLockUiState.Unlocked>(fixture.vm.uiState.value)
}
@Test
fun `key invalidation while still auth-sealed with the setting off clears and keeps the lock`() =
runTest(dispatcher) {
val fixture = fixture(deviceSecure = true, authSealed = true, keyInvalidated = true)
fixture.vm.onForeground()
advanceUntilIdle()
// Biometric re-enrollment during the transitional window: the auth-sealed cache is
// unrecoverable, so it is cleared and app-lock stays ON (CLEAR_AND_REQUIRE_AUTH).
coVerify { fixture.databaseKeyStore.setClearPending() }
verify { fixture.processRestarter.restart() }
coVerify(exactly = 0) { fixture.settingsRepository.setAppLock(false) }
}
@Test
fun `a healthy device still just requires auth during the transitional window`() = runTest(dispatcher) {
val gate = mockk<AppLockGate>(relaxed = true)
every { gate.state } returns LockState.LOCKED
val fixture = fixture(deviceSecure = true, authSealed = true, gate = gate)
fixture.vm.onForeground()
advanceUntilIdle()
// Secure device + valid key: the seal-derived input must not disturb the healthy row —
// REQUIRE_AUTH, no wipe, no restart (the seal is unwrapped after the next auth as before).
verify { gate.onForeground(FOREGROUND_AT, appLockEnabled = true) }
assertIs<AppLockUiState.Locked>(fixture.vm.uiState.value)
coVerify(exactly = 0) { fixture.databaseKeyStore.setClearPending() }
verify(exactly = 0) { fixture.processRestarter.restart() }
}
/**
* A ViewModel over the REAL KeyInvalidationPolicy with app-lock ON and encryptCache OFF (the
* transitional window's setting state), plus mocks pinned to the given device/seal/key state.
*/
private class Fixture(
val vm: AppLockViewModel,
val settingsRepository: SettingsRepository,
val databaseKeyStore: DatabaseKeyStore,
val processRestarter: ProcessRestarter,
)
private fun fixture(
deviceSecure: Boolean,
authSealed: Boolean,
keyInvalidated: Boolean = false,
gate: AppLockGate = mockk(relaxed = true),
): Fixture {
val settingsRepository = mockk<SettingsRepository>(relaxed = true)
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = true, encryptCache = false))
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns authSealed
val appLockManager = mockk<AppLockManager>(relaxed = true)
every { appLockManager.isDeviceSecure() } returns deviceSecure
val databaseKeyCipher = mockk<DatabaseKeyCipher>(relaxed = true)
every { databaseKeyCipher.isInvalidated() } returns keyInvalidated
val processRestarter = mockk<ProcessRestarter>(relaxed = true)
val vm = AppLockViewModel(
context = mockk<Context>(relaxed = true),
settingsRepository = settingsRepository,
appLockManager = appLockManager,
databaseKeyStore = databaseKeyStore,
databaseKeyCipher = databaseKeyCipher,
session = mockk<PassphraseSession>(relaxed = true),
syncScheduler = enqueueingScheduler(),
processRestarter = processRestarter,
gate = gate,
).also { it.defaultDispatcher = dispatcher }
return Fixture(vm, settingsRepository, databaseKeyStore, processRestarter)
}
/** A [SyncScheduler] whose `syncNow()` returns an [Operation] whose result future resolves. */
private fun enqueueingScheduler(): SyncScheduler {
val future = mockk<ListenableFuture<Operation.State.SUCCESS>>()
every { future.get(any(), any()) } returns Operation.SUCCESS
val operation = mockk<Operation> { every { result } returns future }
return mockk<SyncScheduler> { every { syncNow() } returns operation }
}
private companion object {
const val FOREGROUND_AT = 1_000L
}
}