diff --git a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt index d1563e4..6265d05 100644 --- a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt @@ -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() + val releasePassphrase = CompletableDeferred() + // 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 diff --git a/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt new file mode 100644 index 0000000..a32729d --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt @@ -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() + private val accountDao = mockk() + private val connectionFactory = mockk() + private val attachmentUriGrants = mockk() + private val lazyOutbox = mockk> { every { get() } returns outboxDao } + private val lazyAccount = mockk> { every { get() } returns accountDao } + private val lazyConnection = mockk> { every { get() } returns connectionFactory } + private val lazyGrants = mockk> { every { get() } returns attachmentUriGrants } + private val cacheGuard = mockk() + + private fun worker() = SendWorker( + mockk(relaxed = true), + mockk(relaxed = true), + lazyOutbox, + lazyAccount, + mockk(), + mockk(), + 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() } + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/SyncWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SyncWorkerTest.kt new file mode 100644 index 0000000..f0b96a1 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/sync/SyncWorkerTest.kt @@ -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() + private val lazySyncer = mockk> { every { get() } returns mailSyncer } + private val cacheGuard = mockk() + + 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()) + } +}