From 153f8784a793c1b7fed57b50f6ada52d32356e62 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 20:15:19 -0500 Subject: [PATCH] test(sync): instrumented cache-lock deferral for PruneWorker/BackfillWorker Adds an on-device test proving PruneWorker/BackfillWorker defer (Result.retry()) while the encrypted cache is locked, using the REAL EncryptedCacheGuard instead of the mocked guard the JVM PruneWorkerTest/BackfillWorkerTest use (issue #225). The locked state is reproduced with no device auth by mocking SettingsRepository (the same pattern DatabaseProvisionerInstrumentedTest already uses) and leaving a real PassphraseSession never-unlocked. Adds androidx.work:work-testing so the workers can be driven via TestListenableWorkerBuilder with a custom WorkerFactory (their extra Hilt-assisted constructor args aren't supported by the default factory). Closes #226 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 3 + ...WorkerCacheLockDeferralInstrumentedTest.kt | 157 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 3 files changed, 162 insertions(+) create mode 100644 app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f863701..5eb564b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -342,4 +342,7 @@ dependencies { // Instrumented DatabaseProvisioner test: fakes the security/settings collaborators and spies the // DatabaseEncryption object to regression-guard the SQLCipher native-lib load before a keyed open. androidTestImplementation(libs.mockk.android) + // TestListenableWorkerBuilder for the instrumented PruneWorker/BackfillWorker cache-lock-deferral + // test (issue #226): builds a CoroutineWorker with its real (non-Hilt) constructor args on-device. + androidTestImplementation(libs.androidx.work.testing) } diff --git a/app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt new file mode 100644 index 0000000..c222401 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker.Result +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import dagger.Lazy +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.security.EncryptedCacheGuard +import org.libremail.data.security.PassphraseSession +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository + +/** + * 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 + * `DatabaseProvisionerInstrumentedTest` fakes its security/settings collaborators) plus a real, + * never-unlocked [PassphraseSession]. + * + * Caveat (see issue #226): the true "WorkManager cold-starts the process with no UI" can't be reproduced + * in-process. The locked-[PassphraseSession] seam is the faithful stand-in; the auth-bound Keystore path + * stays device-only (see `DatabaseKeyCipher`). + * + * "No `libremail.db` connection is opened" is proven by construction rather than by inspecting the + * on-disk file: [PruneWorker]/[BackfillWorker] can only reach the database through the `Lazy` + * [MailPruner]/[MailBackfiller] passed into their constructor (mirroring `DatabaseModule`'s real Hilt + * wiring), and both gate on [EncryptedCacheGuard.isCacheLocked] BEFORE ever resolving that `Lazy`. + * Asserting the `Lazy` is never resolved is therefore a direct, deterministic proof that no DAO method — + * and so no Room/SQLCipher open — ran, without coupling the test to whatever else the shared + * instrumentation process (or the real `libremail.db` file) happens to be doing. + */ +@RunWith(AndroidJUnit4::class) +class WorkerCacheLockDeferralInstrumentedTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + // A fresh, real instance per test (JUnit4 builds a new test-class instance per method) — never + // unlocked here, so isUnlocked() stays false exactly like a cold process start before the user has + // authenticated. + private val session = PassphraseSession() + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun pruneWorkerDefersWithoutResolvingThePrunerWhileTheRealGuardReportsLocked() = runBlocking { + val cacheGuard = guardFor(appLock = true, encryptCache = true) + assertTrue("precondition: the real guard must report locked", cacheGuard.isCacheLocked()) + + val lazyPruner = mockk>() + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(pruneWorkerFactory(lazyPruner, cacheGuard)) + .build() + + val result = withTimeout(TIMEOUT_MS) { worker.doWork() } + + assertEquals(Result.retry(), result) + // The invariant under test: a locked cache must never even resolve the DB-backed collaborator. + verify(exactly = 0) { lazyPruner.get() } + } + + @Test + fun backfillWorkerDefersWithoutResolvingTheBackfillerWhileTheRealGuardReportsLocked() = runBlocking { + val cacheGuard = guardFor(appLock = true, encryptCache = true) + assertTrue("precondition: the real guard must report locked", cacheGuard.isCacheLocked()) + + val lazyBackfiller = mockk>() + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(backfillWorkerFactory(lazyBackfiller, cacheGuard)) + .build() + + val result = withTimeout(TIMEOUT_MS) { worker.doWork() } + + assertEquals(Result.retry(), result) + verify(exactly = 0) { lazyBackfiller.get() } + } + + /** + * Contrast case so the two tests above can't be vacuously true: the same real guard, driven by the + * same collaborator types, reports UNLOCKED once app-lock is off. [EncryptedCacheGuard] otherwise has + * no test of its own anywhere in the suite (only callers mocking the whole guard), so this is also + * this class's only direct coverage of its boolean logic. + */ + @Test + fun theRealGuardReportsUnlockedWhenAppLockIsOff() = runBlocking { + val cacheGuard = guardFor(appLock = false, encryptCache = true) + + assertFalse(cacheGuard.isCacheLocked()) + } + + /** Second branch of the same contrast: an authenticated session unlocks the cache even with app-lock on. */ + @Test + fun theRealGuardReportsUnlockedOnceTheSessionIsUnlocked() = runBlocking { + val cacheGuard = guardFor(appLock = true, encryptCache = true) + assertTrue("precondition: locked before authentication", cacheGuard.isCacheLocked()) + + session.unlock(FAKE_PASSPHRASE) + + assertFalse(cacheGuard.isCacheLocked()) + } + + /** A real [EncryptedCacheGuard] over a fixed (mocked) [SettingsRepository] and the real [session]. */ + private fun guardFor(appLock: Boolean, encryptCache: Boolean): EncryptedCacheGuard { + val settingsRepository = mockk() + val settings = AppSettings(appLock = appLock, encryptCache = encryptCache) + every { settingsRepository.settings } returns flowOf(settings) + return EncryptedCacheGuard(settingsRepository, session) + } + + private fun pruneWorkerFactory(lazyPruner: Lazy, cacheGuard: EncryptedCacheGuard) = + object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = PruneWorker(appContext, workerParameters, lazyPruner, cacheGuard) + } + + private fun backfillWorkerFactory(lazyBackfiller: Lazy, cacheGuard: EncryptedCacheGuard) = + object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = BackfillWorker(appContext, workerParameters, lazyBackfiller, cacheGuard) + } + + private companion object { + // Generous bound: a locked run must fail fast (no passphrase await), so this is only ever + // approached by a real regression — a passing run returns almost immediately. + const val TIMEOUT_MS = 5_000L + + // 64 hex chars, matching DatabaseKeyStore's passphrase format. Never used to open a real database. + const val FAKE_PASSPHRASE = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db8d291..f61fb86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -92,6 +92,8 @@ androidx-paging-testing = { group = "androidx.paging", name = "paging-testing", # DataStore (settings) / WorkManager (sync) — wired in later increments androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +# TestListenableWorkerBuilder for instrumented CoroutineWorker tests (issue #226). +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } # Coroutines kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }