test(sync): cover the cache-lock gate on SyncWorker/SendWorker + the provisioner await

Locks in the "pre-auth DB entry point defers while the encrypted cache is locked"
invariant that had zero coverage (which is how the PruneWorker/BackfillWorker gap
in #224 slipped in). Adds SyncWorkerTest and SendWorkerTest (locked -> retry with
the Lazy DB deps never resolved; unlocked -> runs), and a DatabaseProvisionerTest
case proving prepareCache() suspends on an auth-bound resolvePassphrase until it
resolves.

IdleService shares the same guard but is an Android Service (its start path needs
startForeground/Context), so its gate is covered by the instrumented test (#226)
rather than a JVM unit test.

Closes #225

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 11:04:34 -05:00
co-authored by Claude Opus 4.8
parent 82ed7dad1a
commit 6d75bf5c6d
3 changed files with 150 additions and 0 deletions
@@ -28,6 +28,7 @@ import org.libremail.data.settings.SettingsRepository
import java.io.File
import java.util.concurrent.Executors
import kotlin.test.assertEquals
import kotlin.test.assertFalse
/**
* [DatabaseProvisioner] holds the one-time startup sequence that `DatabaseModule.provideDatabase` used
@@ -97,6 +98,28 @@ class DatabaseProvisionerTest {
verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() }
}
@Test
fun `prepareCache suspends on the auth-bound passphrase until it resolves`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = true))
val enteredResolve = CompletableDeferred<Unit>()
val releasePassphrase = CompletableDeferred<String>()
// Model the auth-sealed key: resolvePassphrase parks until the user authenticates. Keeping this
// await off a background thread is exactly why the pre-auth DB entry points gate on
// EncryptedCacheGuard — this pins that prepareCache really does block on it.
coEvery { keyStore.resolvePassphrase(true) } coAnswers {
enteredResolve.complete(Unit)
releasePassphrase.await()
}
val prepared = async { provisioner().prepareCache() }
enteredResolve.await() // the startup sequence has reached the passphrase await
assertFalse(prepared.isCompleted, "prepareCache must not complete while the passphrase is unresolved")
releasePassphrase.complete(PASSPHRASE)
assertEquals(CacheOpenMode.Encrypted(PASSPHRASE), prepared.await())
}
@Test
fun `a pending clear wipes and resets the seals before the migrator runs`() = runTest {
coEvery { keyStore.isClearPending() } returns true
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import androidx.work.ListenableWorker.Result
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.data.attachment.AttachmentUriGrants
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.mail.GraphSender
import org.libremail.mail.SmtpSender
import kotlin.test.assertEquals
/**
* [SendWorker] already gates on [EncryptedCacheGuard]; this locks that invariant in — while the cache
* is locked it must defer without resolving any of its (`Lazy`) DB-backed dependencies (resolving any
* of them opens the Room DB, which blocks on the passphrase await).
*/
class SendWorkerTest {
private val outboxDao = mockk<OutboxDao>()
private val accountDao = mockk<AccountDao>()
private val connectionFactory = mockk<MailConnectionFactory>()
private val attachmentUriGrants = mockk<AttachmentUriGrants>()
private val lazyOutbox = mockk<Lazy<OutboxDao>> { every { get() } returns outboxDao }
private val lazyAccount = mockk<Lazy<AccountDao>> { every { get() } returns accountDao }
private val lazyConnection = mockk<Lazy<MailConnectionFactory>> { every { get() } returns connectionFactory }
private val lazyGrants = mockk<Lazy<AttachmentUriGrants>> { every { get() } returns attachmentUriGrants }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private fun worker() = SendWorker(
mockk(relaxed = true),
mockk(relaxed = true),
lazyOutbox,
lazyAccount,
mockk<SmtpSender>(),
mockk<GraphSender>(),
lazyConnection,
cacheGuard,
lazyGrants,
)
@Test
fun `retries without resolving any DB dependency when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
assertEquals(Result.retry(), worker().doWork())
verify(exactly = 0) { lazyOutbox.get() }
verify(exactly = 0) { lazyAccount.get() }
verify(exactly = 0) { lazyConnection.get() }
verify(exactly = 0) { lazyGrants.get() }
}
@Test
fun `drains the outbox when the cache is unlocked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { outboxDao.getAll() } returns emptyList()
assertEquals(Result.success(), worker().doWork())
coVerify(exactly = 1) { outboxDao.getAll() }
}
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import androidx.work.ListenableWorker
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.data.security.EncryptedCacheGuard
import kotlin.test.assertEquals
/**
* [SyncWorker] already gates on [EncryptedCacheGuard]; this locks that invariant in as regression
* cover for the class of bug fixed in PruneWorker/BackfillWorker — while the cache is locked it must
* defer without resolving the (`Lazy`) [MailSyncer] (whose construction opens the Room DB).
*/
class SyncWorkerTest {
private val mailSyncer = mockk<MailSyncer>()
private val lazySyncer = mockk<Lazy<MailSyncer>> { every { get() } returns mailSyncer }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private fun worker() = SyncWorker(mockk(relaxed = true), mockk(relaxed = true), lazySyncer, cacheGuard)
@Test
fun `retries without resolving the syncer when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
assertEquals(ListenableWorker.Result.retry(), worker().doWork())
verify(exactly = 0) { lazySyncer.get() }
coVerify(exactly = 0) { mailSyncer.syncAll() }
}
@Test
fun `syncs and succeeds when the cache is unlocked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { mailSyncer.syncAll() } returns Result.success(0)
assertEquals(ListenableWorker.Result.success(), worker().doWork())
coVerify(exactly = 1) { mailSyncer.syncAll() }
}
@Test
fun `retries when syncing fails`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { mailSyncer.syncAll() } returns Result.failure(IllegalStateException("boom"))
assertEquals(ListenableWorker.Result.retry(), worker().doWork())
}
}