diff --git a/app/src/test/kotlin/org/libremail/data/attachment/AttachmentUriGrantsTest.kt b/app/src/test/kotlin/org/libremail/data/attachment/AttachmentUriGrantsTest.kt index b9ff6f3..e99ee50 100644 --- a/app/src/test/kotlin/org/libremail/data/attachment/AttachmentUriGrantsTest.kt +++ b/app/src/test/kotlin/org/libremail/data/attachment/AttachmentUriGrantsTest.kt @@ -1,14 +1,33 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.attachment +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Test +import org.libremail.data.local.dao.DraftDao +import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.entity.DraftEntity +import org.libremail.data.local.entity.OutboxEntity import kotlin.test.assertEquals /** * The release decision that [AttachmentUriGrants] runs after a draft/outbox row is deleted: a picked * URI's persistable grant is released only when no *remaining* draft or outbox row still references - * it. The Android release call itself (`releasePersistableUriPermission`) is a thin wrapper; the - * decision here is the part worth pinning. + * it. The pure [unreferencedUris] decision is pinned first; the instance-method tests then wire that + * decision to the DAOs and the (mocked) content resolver, covering the empty-set short-circuit, the + * still-referenced guard against drafts and outbox rows alike, and the swallowed SecurityException + * from releasing a grant the app never actually held. */ class AttachmentUriGrantsTest { @@ -47,4 +66,95 @@ class AttachmentUriGrantsTest { unreferencedUris(candidates = listOf("content://a"), referenced = setOf("content://a")), ) } + + // --- releaseUnreferenced: wiring the decision to the DAOs and the content resolver -------------- + + private val draftDao = mockk() + private val outboxDao = mockk() + private val resolver = mockk(relaxed = true) + private val context = mockk() + private val grants = AttachmentUriGrants(context, draftDao, outboxDao) + + @After + fun tearDown() = unmockkAll() + + private fun draft(vararg uris: String) = DraftEntity( + id = "draft-${uris.joinToString()}", + accountId = "a", + toAddresses = "", + ccAddresses = "", + subject = "", + body = "", + updatedAt = 0L, + attachments = attachmentsJson(*uris), + ) + + private fun outbox(vararg uris: String) = OutboxEntity( + id = "outbox-${uris.joinToString()}", + accountId = "a", + toAddresses = "", + ccAddresses = "", + subject = "", + body = "", + createdAt = 0L, + attachments = attachmentsJson(*uris), + ) + + private fun attachmentsJson(vararg uris: String): String = + if (uris.isEmpty()) "" else uris.joinToString(",", "[", "]") { """{"uri":"$it","name":"n"}""" } + + private fun stubResolver() { + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk(relaxed = true) + every { context.contentResolver } returns resolver + } + + @Test + fun `releaseUnreferenced short-circuits on an empty uri set without querying the daos`() = runTest { + grants.releaseUnreferenced(emptyList()) + + coVerify(exactly = 0) { draftDao.getAll() } + coVerify(exactly = 0) { outboxDao.getAll() } + } + + @Test + fun `releaseUnreferenced releases only the uris no remaining draft or outbox row references`() = runTest { + stubResolver() + // "content://shared" is still attached to a live draft; "content://gone" is referenced by nobody. + coEvery { draftDao.getAll() } returns listOf(draft("content://shared")) + coEvery { outboxDao.getAll() } returns emptyList() + + grants.releaseUnreferenced(listOf("content://gone", "content://shared")) + + // The still-referenced uri is kept (never parsed for release); only the orphan is released. + verify(exactly = 1) { Uri.parse("content://gone") } + verify(exactly = 0) { Uri.parse("content://shared") } + verify(exactly = 1) { + resolver.releasePersistableUriPermission(any(), eq(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + } + } + + @Test + fun `releaseUnreferenced keeps a uri still referenced by a queued outbox row`() = runTest { + stubResolver() + coEvery { draftDao.getAll() } returns emptyList() + coEvery { outboxDao.getAll() } returns listOf(outbox("content://queued")) + + grants.releaseUnreferenced(listOf("content://queued")) + + verify(exactly = 0) { resolver.releasePersistableUriPermission(any(), any()) } + } + + @Test + fun `releaseUnreferenced swallows the SecurityException from releasing a grant it never held`() = runTest { + stubResolver() + coEvery { draftDao.getAll() } returns emptyList() + coEvery { outboxDao.getAll() } returns emptyList() + every { resolver.releasePersistableUriPermission(any(), any()) } throws SecurityException("no grant held") + + // Releasing a never-persisted uri throws SecurityException inside runCatching; it must not escape. + grants.releaseUnreferenced(listOf("content://never-held")) + + verify { resolver.releasePersistableUriPermission(any(), any()) } + } } diff --git a/app/src/test/kotlin/org/libremail/data/security/CredentialStoreTest.kt b/app/src/test/kotlin/org/libremail/data/security/CredentialStoreTest.kt new file mode 100644 index 0000000..9ed451e --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/security/CredentialStoreTest.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.libremail.data.local.dao.CredentialDao +import org.libremail.data.local.entity.CredentialEntity +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * [CredentialStore] wraps a single encrypted-secret row per account: it encrypts on the way in, + * decrypts on the way out, and reports a missing row as `null`. The Keystore crypto is mocked so the + * store's own read/modify/write and null-handling are what these pin — the AES-GCM round-trip itself + * is [KeystoreCrypto]'s concern and is device-bound. + */ +class CredentialStoreTest { + + private val crypto = mockk() + private val dao = mockk() + private val store = CredentialStore(crypto, dao) + + @Test + fun `saveSecret encrypts the secret and upserts it under the account id`() = runTest { + every { crypto.encrypt("token") } returns "cipher(token)" + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + store.saveSecret("acct", "token") + + assertEquals("acct", saved.captured.accountId) + assertEquals("cipher(token)", saved.captured.encryptedSecret) + } + + @Test + fun `loadSecret decrypts the stored ciphertext`() = runTest { + coEvery { dao.getById("acct") } returns CredentialEntity("acct", "cipher(token)") + every { crypto.decrypt("cipher(token)") } returns "token" + + assertEquals("token", store.loadSecret("acct")) + } + + @Test + fun `loadSecret returns null when the account has no stored secret`() = runTest { + coEvery { dao.getById("acct") } returns null + + assertNull(store.loadSecret("acct")) + // With no row there is nothing to decrypt. + verify(exactly = 0) { crypto.decrypt(any()) } + } + + @Test + fun `delete removes the account's secret row`() = runTest { + coEvery { dao.deleteById("acct") } just Runs + + store.delete("acct") + + coVerify { dao.deleteById("acct") } + } +} diff --git a/app/src/test/kotlin/org/libremail/data/security/EncryptedCacheGuardTest.kt b/app/src/test/kotlin/org/libremail/data/security/EncryptedCacheGuardTest.kt new file mode 100644 index 0000000..3e5cd3c --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/security/EncryptedCacheGuardTest.kt @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +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. + */ +class EncryptedCacheGuardTest { + + private val settingsRepository = mockk() + private val session = mockk() + + private suspend fun cacheLocked(appLock: Boolean, encryptCache: Boolean, unlocked: Boolean): Boolean { + every { settingsRepository.settings } returns + flowOf(AppSettings(appLock = appLock, encryptCache = encryptCache)) + every { session.isUnlocked() } returns unlocked + return EncryptedCacheGuard(settingsRepository, session).isCacheLocked() + } + + @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)) + } + + @Test + fun `not locked once the passphrase session is unlocked`() = runTest { + assertFalse(cacheLocked(appLock = true, encryptCache = true, unlocked = true)) + } + + @Test + fun `not locked when the cache is not encrypted`() = runTest { + assertFalse(cacheLocked(appLock = true, encryptCache = false, unlocked = false)) + } + + @Test + fun `not locked when app-lock is off`() = runTest { + assertFalse(cacheLocked(appLock = false, encryptCache = true, unlocked = false)) + } + + @Test + fun `not locked when neither app-lock nor encrypted cache is on`() = runTest { + assertFalse(cacheLocked(appLock = false, encryptCache = false, unlocked = true)) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/settings/AccountSettingsRepositoryTest.kt b/app/src/test/kotlin/org/libremail/data/settings/AccountSettingsRepositoryTest.kt index 4a9a08d..d43d281 100644 --- a/app/src/test/kotlin/org/libremail/data/settings/AccountSettingsRepositoryTest.kt +++ b/app/src/test/kotlin/org/libremail/data/settings/AccountSettingsRepositoryTest.kt @@ -1,17 +1,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.settings +import app.cash.turbine.test import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.slot +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test import org.libremail.data.local.dao.AccountSettingsDao import org.libremail.data.local.entity.AccountSettingsEntity import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class AccountSettingsRepositoryTest { @@ -65,4 +70,101 @@ class AccountSettingsRepositoryTest { coVerify(exactly = 0) { dao.upsert(any()) } } + + @Test + fun `observe maps the stored row to the domain model`() = runTest { + every { dao.observe("acct") } returns flowOf( + AccountSettingsEntity("acct", signature = "sig", signatureEnabled = false, notificationsEnabled = true), + ) + + repository.observe("acct").test { + val settings = awaitItem() + assertEquals("sig", settings.signature) + assertFalse(settings.signatureEnabled) + assertTrue(settings.notificationsEnabled) + awaitComplete() + } + } + + @Test + fun `observe emits defaults for an account with no stored row`() = runTest { + every { dao.observe("acct") } returns flowOf(null) + + repository.observe("acct").test { + val settings = awaitItem() + assertEquals("acct", settings.accountId) + assertEquals("", settings.signature) + assertTrue(settings.signatureEnabled) + awaitComplete() + } + } + + @Test + fun `setSignatureEnabled reads, modifies, and writes the row`() = runTest { + coEvery { dao.get("acct") } returns + AccountSettingsEntity("acct", signature = "keep", signatureEnabled = true, notificationsEnabled = true) + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.setSignatureEnabled("acct", false) + + assertFalse(saved.captured.signatureEnabled) + // Untouched fields are preserved. + assertEquals("keep", saved.captured.signature) + } + + @Test + fun `setNotificationsEnabled reads, modifies, and writes the row`() = runTest { + coEvery { dao.get("acct") } returns AccountSettingsEntity("acct", notificationsEnabled = true) + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.setNotificationsEnabled("acct", false) + + assertFalse(saved.captured.notificationsEnabled) + } + + @Test + fun `setRetentionCount clamps a negative override to zero`() = runTest { + coEvery { dao.get("acct") } returns AccountSettingsEntity("acct") + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.setRetentionCount("acct", -5) + + assertEquals(0, saved.captured.retentionCount) + } + + @Test + fun `setRetentionCount preserves null as inherit-the-global-default`() = runTest { + coEvery { dao.get("acct") } returns AccountSettingsEntity("acct", retentionCount = 10) + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.setRetentionCount("acct", null) + + assertNull(saved.captured.retentionCount) + } + + @Test + fun `setRetentionMonths clamps a negative override to zero`() = runTest { + coEvery { dao.get("acct") } returns AccountSettingsEntity("acct") + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.setRetentionMonths("acct", -3) + + assertEquals(0, saved.captured.retentionMonths) + } + + @Test + fun `setRetentionMonths keeps a positive override as given`() = runTest { + coEvery { dao.get("acct") } returns AccountSettingsEntity("acct") + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.setRetentionMonths("acct", 6) + + assertEquals(6, saved.captured.retentionMonths) + } } diff --git a/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt index b38c0df..3430e18 100644 --- a/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt +++ b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt @@ -1,17 +1,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.settings +import app.cash.turbine.test import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.slot +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test import org.libremail.data.local.dao.SignatureDao import org.libremail.data.local.entity.SignatureEntity +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class SignatureRepositoryTest { @@ -71,4 +76,75 @@ class SignatureRepositoryTest { repository.setDefault("acct", "s1") coVerify { dao.setDefault("acct", "s1") } } + + @Test + fun `observeForAccount maps entity rows to domain signatures`() = runTest { + every { dao.observeForAccount("acct") } returns flowOf( + listOf(entity("s1", isDefault = true), entity("s2", isDefault = false)), + ) + + repository.observeForAccount("acct").test { + val signatures = awaitItem() + assertEquals(listOf("s1", "s2"), signatures.map { it.id }) + assertEquals("acct", signatures.first().accountId) + assertTrue(signatures.first().isDefault) + awaitComplete() + } + } + + @Test + fun `get maps the stored row to a domain signature`() = runTest { + coEvery { dao.getById("s1") } returns entity("s1", isDefault = true) + + val signature = repository.get("s1") + + assertEquals("s1", signature?.id) + assertEquals("

x

", signature?.html) + assertTrue(signature?.isDefault == true) + } + + @Test + fun `get returns null for an unknown id`() = runTest { + coEvery { dao.getById("missing") } returns null + + assertNull(repository.get("missing")) + } + + @Test + fun `getDefault returns the account's default signature`() = runTest { + coEvery { dao.getDefault("acct") } returns entity("s1", isDefault = true) + + assertEquals("s1", repository.getDefault("acct")?.id) + } + + @Test + fun `getDefault returns null when the account has no default`() = runTest { + coEvery { dao.getDefault("acct") } returns null + + assertNull(repository.getDefault("acct")) + } + + @Test + fun `update rewrites the name and html of an existing signature`() = runTest { + coEvery { dao.getById("s1") } returns entity("s1", isDefault = true) + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.update("s1", "Renamed", "

new

") + + assertEquals("Renamed", saved.captured.name) + assertEquals("

new

", saved.captured.contentHtml) + // Editing a signature leaves its default flag and account untouched. + assertTrue(saved.captured.isDefault) + assertEquals("acct", saved.captured.accountId) + } + + @Test + fun `update is a no-op for an unknown id`() = runTest { + coEvery { dao.getById("missing") } returns null + + repository.update("missing", "X", "

y

") + + coVerify(exactly = 0) { dao.upsert(any()) } + } } diff --git a/app/src/test/kotlin/org/libremail/data/sync/SyncSchedulerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SyncSchedulerTest.kt index 81d267c..35da23f 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/SyncSchedulerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/SyncSchedulerTest.kt @@ -66,6 +66,19 @@ class SyncSchedulerTest { } } + @Test + fun `periodic report purge is enqueued with UPDATE so upgrades re-apply its schedule`() { + scheduler.schedulePeriodicReportPurge() + + verify { + workManager.enqueueUniquePeriodicWork( + "libremail_periodic_report_purge", + ExistingPeriodicWorkPolicy.UPDATE, + any(), + ) + } + } + // The one-shot kicks are a separate concern from #96 and keep their existing policies: syncNow and // pruneNow REPLACE for a clean immediate attempt; backfillNow KEEPs an in-flight all-account run. diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt index 6eaeb81..e9ba07c 100644 --- a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -15,6 +15,7 @@ import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.model.SmtpParams import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -288,4 +289,96 @@ class SmtpSenderTest { // Jakarta Mail strips the Bcc header before transmission, so it must not appear on the wire. received.forEach { assertNull(it.getHeader("Bcc")) } } + + @Test + fun `send copies cc recipients on the wire`() = runTest { + sender.send( + params = params(), + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + cc = "carol@example.org", + subject = "With CC", + body = "Hello cc.", + ), + ) + + // To + Cc are both in the envelope, so GreenMail delivers two copies; Cc stays on the wire. + greenMail.waitForIncomingEmail(2) + val received = greenMail.receivedMessages + assertEquals(2, received.size) + assertTrue(GreenMailUtil.getWholeMessage(received[0]).contains("carol@example.org"), "missing Cc header") + } + + @Test + fun `an inline image plus a regular attachment nests a related body inside mixed`() = runTest { + val image = File.createTempFile("libremail-inline", ".png").apply { writeText("PNGDATA") } + val doc = File.createTempFile("libremail-doc", ".txt").apply { writeText("attached doc") } + + sender.send( + params = params(), + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "Inline + file", + body = "See image and file", + bodyHtml = "

See

", + ), + attachments = listOf( + SendableAttachment(image, contentId = "logo@libremail", isInline = true), + SendableAttachment(doc), + ), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + // A regular attachment makes the top level multipart/mixed; the inline image wraps the body in + // a multipart/related nested as the first mixed part (the `bodyPart` inline branch). + assertTrue(received.contentType.contains("multipart/mixed", ignoreCase = true), received.contentType) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("multipart/related", ignoreCase = true), "inline body must be wrapped in related") + assertTrue(raw.contains(""), "inline part must carry a matching Content-ID") + assertTrue(raw.contains(doc.name), "regular attachment must be present") + image.delete() + doc.delete() + } + + @Test + fun `send fails and delivers nothing when a required STARTTLS upgrade is unavailable`() = runTest { + // The plain GreenMail server does not advertise STARTTLS; with a strict (required) STARTTLS + // policy the client refuses to fall back to plaintext, so send throws and nothing is delivered. + // Exercises the STARTTLS + XOAUTH2 branches of the property builder and the connect error path. + assertFailsWith { + sender.send( + params = params(security = MailSecurity.STARTTLS, useXoauth2 = true), + from = "sender@example.org", + message = OutgoingMessage(accountId = "x", to = "bob@example.org", subject = "No TLS", body = "x"), + ) + } + assertTrue(greenMail.receivedMessages.isEmpty()) + } + + @Test + fun `send fails when implicit TLS cannot be negotiated`() = runTest { + // Driving the implicit-TLS (SSL_TLS) path at the plain SMTP port makes the handshake fail, so + // send throws — covering the ssl.enable branch of the property builder and the connect error path. + assertFailsWith { + sender.send( + params = params(security = MailSecurity.SSL_TLS), + from = "sender@example.org", + message = OutgoingMessage(accountId = "x", to = "bob@example.org", subject = "No TLS", body = "x"), + ) + } + } + + private fun params(security: MailSecurity = MailSecurity.NONE, useXoauth2: Boolean = false) = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = security, + username = "sender@example.org", + secret = "secret", + useXoauth2 = useXoauth2, + ) } diff --git a/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt index 49f16fd..36890cf 100644 --- a/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/lock/AppLockViewModelTest.kt @@ -17,6 +17,7 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -40,9 +41,12 @@ import org.libremail.data.settings.AppSettings import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler import org.libremail.restart.ProcessRestarter +import java.util.concurrent.ExecutionException import java.util.concurrent.TimeoutException import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue /** * Wiring guard for #101: the app-lock gate must be an INJECTED, application-scoped dependency so its @@ -172,6 +176,103 @@ class AppLockViewModelTest { verify { processRestarter.restart() } } + @Test + fun `recovery still restarts when the re-sync enqueue fails to persist`() = runTest(dispatcher) { + val (future, syncScheduler) = enqueueingScheduler() + every { future.get(any(), any()) } throws ExecutionException(IllegalStateException("insert failed")) + val processRestarter = mockk(relaxed = true) + val vm = clearOnForegroundViewModel(syncScheduler = syncScheduler, processRestarter = processRestarter) + + vm.onForeground() + advanceUntilIdle() + + // A failed enqueue is swallowed like a timeout: recovery restarts anyway. + verify { future.get(any(), any()) } + verify { processRestarter.restart() } + } + + @Test + fun `recovery still restarts when awaiting the re-sync enqueue is interrupted`() = runTest(dispatcher) { + val (future, syncScheduler) = enqueueingScheduler() + every { future.get(any(), any()) } throws InterruptedException("interrupted") + val processRestarter = mockk(relaxed = true) + val vm = clearOnForegroundViewModel(syncScheduler = syncScheduler, processRestarter = processRestarter) + + vm.onForeground() + // The recovery runs inline on this (unconfined) thread and re-sets its interrupt flag; capture and + // clear it immediately so it can neither disrupt the scheduler nor leak into a later test. + val reInterrupted = Thread.interrupted() + advanceUntilIdle() + + // An interrupt while awaiting the enqueue is swallowed (flag preserved) and recovery restarts anyway. + assertTrue(reInterrupted) + verify { future.get(any(), any()) } + verify { processRestarter.restart() } + } + + @Test + fun `onAuthenticated rethrows a cancellation while unwrapping and never wipes the cache`() = runTest(dispatcher) { + stubLog() + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns false + val databaseKeyStore = mockk(relaxed = true) + coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true + coEvery { databaseKeyStore.unlockWithAuth() } throws CancellationException("scope cancelled") + val databaseKeyCipher = mockk(relaxed = true) + every { databaseKeyCipher.hasKey() } returns true + val processRestarter = mockk(relaxed = true) + val vm = viewModel( + gate = mockk(relaxed = true), + session = session, + databaseKeyStore = databaseKeyStore, + databaseKeyCipher = databaseKeyCipher, + processRestarter = processRestarter, + ) + + vm.onAuthenticated() + advanceUntilIdle() + + // A cancellation is rethrown, never mapped to UNRECOVERABLE, so the cache is not wiped/restarted. + coVerify { databaseKeyStore.unlockWithAuth() } + coVerify(exactly = 0) { databaseKeyStore.setClearPending() } + verify(exactly = 0) { processRestarter.restart() } + } + + @Test + fun `each lock emission carries a distinct nonce so a repeat lock still updates the UI`() = runTest(dispatcher) { + val vm = viewModel(gate = mockk(relaxed = true)) + + vm.onAuthError("boom") + val first = assertIs(vm.uiState.value).nonce + vm.onAuthError("boom") + val second = assertIs(vm.uiState.value).nonce + + // StateFlow conflates equal values; the bumped nonce guarantees the repeated lock still emits. + assertNotEquals(first, second) + } + + @Test + fun `onBackground covers the app content when app-lock is on and the app was showing`() = runTest(dispatcher) { + mockkStatic(SystemClock::class) + every { SystemClock.elapsedRealtime() } returns 2_000L + val gate = mockk(relaxed = true) + every { gate.state } returns LockState.UNLOCKED + val session = mockk(relaxed = true) + every { session.isUnlocked() } returns true + val vm = viewModel(gate = gate, session = session, appLock = true) + + // Reach the Unlocked state (app content showing) via a warm re-auth... + vm.onAuthenticated() + advanceUntilIdle() + assertIs(vm.uiState.value) + + // ...then backgrounding must immediately cover it so nothing sensitive lands in the recents snapshot. + vm.onBackground() + + assertIs(vm.uiState.value) + verify { gate.onBackground(2_000L) } + } + // --- onForeground: LockAction dispatch (issue #100) ------------------------------------------ @Test diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt index 83f6901..3b74045 100644 --- a/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt @@ -163,4 +163,16 @@ class StartupReportViewModelTest { assertNull(vm.pendingCrash.value) assertNull(store.find("c")) } + + @Test + fun `the injected constructor gates the crash age against the real system clock`() = runTest(dispatcher) { + val store = store() + // Created against the real wall clock so the injected `now = System.currentTimeMillis` gate + // (exercised only via the @Inject constructor, not the test's fixed-clock one) sees it as fresh. + store.save(crash("c", createdAt = System.currentTimeMillis())) + val vm = StartupReportViewModel(store) + subscribe(vm) + + assertEquals("c", vm.pendingCrash.value?.id) + } }