test(coverage): close clean JVM-testable coverage gaps
Close the cleanly JVM-testable coverage gaps from the Phase-2 JaCoCo audit (#288), bringing each targeted class to 100% line coverage: - AccountSettingsRepository: observe Flow + the sibling setters (signature/notifications enabled, retention count/months incl. clamp). - SignatureRepository: observeForAccount/get/getDefault/update + toDomain. - CredentialStore (new): save/load/delete with a mocked KeystoreCrypto. - EncryptedCacheGuard (new): the isCacheLocked() truth table. - AttachmentUriGrants: releaseUnreferenced/referencedUris wiring. - SyncScheduler: schedulePeriodicReportPurge. - AppLockViewModel: nonce, onBackground cover branch, unwrap cancellation rethrow, awaitSyncEnqueue execution/interrupt branches. - SmtpSender: send error paths (SSL/STARTTLS) + cc + inline+regular body. - StartupReportViewModel: the @Inject real-clock constructor. All test-only. Unit tests + jacocoTestReport + ktlintCheck + detekt green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,33 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.attachment
|
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.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
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The release decision that [AttachmentUriGrants] runs after a draft/outbox row is deleted: a picked
|
* 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
|
* 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
|
* it. The pure [unreferencedUris] decision is pinned first; the instance-method tests then wire that
|
||||||
* decision here is the part worth pinning.
|
* 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 {
|
class AttachmentUriGrantsTest {
|
||||||
|
|
||||||
@@ -47,4 +66,95 @@ class AttachmentUriGrantsTest {
|
|||||||
unreferencedUris(candidates = listOf("content://a"), referenced = setOf("content://a")),
|
unreferencedUris(candidates = listOf("content://a"), referenced = setOf("content://a")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- releaseUnreferenced: wiring the decision to the DAOs and the content resolver --------------
|
||||||
|
|
||||||
|
private val draftDao = mockk<DraftDao>()
|
||||||
|
private val outboxDao = mockk<OutboxDao>()
|
||||||
|
private val resolver = mockk<ContentResolver>(relaxed = true)
|
||||||
|
private val context = mockk<Context>()
|
||||||
|
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()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<KeystoreCrypto>()
|
||||||
|
private val dao = mockk<CredentialDao>()
|
||||||
|
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<CredentialEntity>()
|
||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SettingsRepository>()
|
||||||
|
private val session = mockk<PassphraseSession>()
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.settings
|
package org.libremail.data.settings
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.just
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.local.dao.AccountSettingsDao
|
import org.libremail.data.local.dao.AccountSettingsDao
|
||||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class AccountSettingsRepositoryTest {
|
class AccountSettingsRepositoryTest {
|
||||||
@@ -65,4 +70,101 @@ class AccountSettingsRepositoryTest {
|
|||||||
|
|
||||||
coVerify(exactly = 0) { dao.upsert(any()) }
|
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<AccountSettingsEntity>()
|
||||||
|
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<AccountSettingsEntity>()
|
||||||
|
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<AccountSettingsEntity>()
|
||||||
|
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<AccountSettingsEntity>()
|
||||||
|
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<AccountSettingsEntity>()
|
||||||
|
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<AccountSettingsEntity>()
|
||||||
|
coEvery { dao.upsert(capture(saved)) } just Runs
|
||||||
|
|
||||||
|
repository.setRetentionMonths("acct", 6)
|
||||||
|
|
||||||
|
assertEquals(6, saved.captured.retentionMonths)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.settings
|
package org.libremail.data.settings
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.just
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.local.dao.SignatureDao
|
import org.libremail.data.local.dao.SignatureDao
|
||||||
import org.libremail.data.local.entity.SignatureEntity
|
import org.libremail.data.local.entity.SignatureEntity
|
||||||
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class SignatureRepositoryTest {
|
class SignatureRepositoryTest {
|
||||||
@@ -71,4 +76,75 @@ class SignatureRepositoryTest {
|
|||||||
repository.setDefault("acct", "s1")
|
repository.setDefault("acct", "s1")
|
||||||
coVerify { dao.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("<p>x</p>", 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<SignatureEntity>()
|
||||||
|
coEvery { dao.upsert(capture(saved)) } just Runs
|
||||||
|
|
||||||
|
repository.update("s1", "Renamed", "<p>new</p>")
|
||||||
|
|
||||||
|
assertEquals("Renamed", saved.captured.name)
|
||||||
|
assertEquals("<p>new</p>", 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", "<p>y</p>")
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { dao.upsert(any()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PeriodicWorkRequest>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The one-shot kicks are a separate concern from #96 and keep their existing policies: syncNow and
|
// 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.
|
// pruneNow REPLACE for a clean immediate attempt; backfillNow KEEPs an in-flight all-account run.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.libremail.domain.model.OutgoingMessage
|
|||||||
import org.libremail.domain.model.SmtpParams
|
import org.libremail.domain.model.SmtpParams
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
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.
|
// Jakarta Mail strips the Bcc header before transmission, so it must not appear on the wire.
|
||||||
received.forEach { assertNull(it.getHeader("Bcc")) }
|
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 = "<p>See <img src=\"cid:logo@libremail\"></p>",
|
||||||
|
),
|
||||||
|
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("<logo@libremail>"), "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<Exception> {
|
||||||
|
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<Exception> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import io.mockk.mockkObject
|
|||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.flowOf
|
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.settings.SettingsRepository
|
||||||
import org.libremail.data.sync.SyncScheduler
|
import org.libremail.data.sync.SyncScheduler
|
||||||
import org.libremail.restart.ProcessRestarter
|
import org.libremail.restart.ProcessRestarter
|
||||||
|
import java.util.concurrent.ExecutionException
|
||||||
import java.util.concurrent.TimeoutException
|
import java.util.concurrent.TimeoutException
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertIs
|
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
|
* 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() }
|
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<ProcessRestarter>(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<ProcessRestarter>(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<PassphraseSession>(relaxed = true)
|
||||||
|
every { session.isUnlocked() } returns false
|
||||||
|
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
|
||||||
|
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns true
|
||||||
|
coEvery { databaseKeyStore.unlockWithAuth() } throws CancellationException("scope cancelled")
|
||||||
|
val databaseKeyCipher = mockk<DatabaseKeyCipher>(relaxed = true)
|
||||||
|
every { databaseKeyCipher.hasKey() } returns true
|
||||||
|
val processRestarter = mockk<ProcessRestarter>(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<AppLockUiState.Locked>(vm.uiState.value).nonce
|
||||||
|
vm.onAuthError("boom")
|
||||||
|
val second = assertIs<AppLockUiState.Locked>(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<AppLockGate>(relaxed = true)
|
||||||
|
every { gate.state } returns LockState.UNLOCKED
|
||||||
|
val session = mockk<PassphraseSession>(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<AppLockUiState.Unlocked>(vm.uiState.value)
|
||||||
|
|
||||||
|
// ...then backgrounding must immediately cover it so nothing sensitive lands in the recents snapshot.
|
||||||
|
vm.onBackground()
|
||||||
|
|
||||||
|
assertIs<AppLockUiState.Checking>(vm.uiState.value)
|
||||||
|
verify { gate.onBackground(2_000L) }
|
||||||
|
}
|
||||||
|
|
||||||
// --- onForeground: LockAction dispatch (issue #100) ------------------------------------------
|
// --- onForeground: LockAction dispatch (issue #100) ------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -163,4 +163,16 @@ class StartupReportViewModelTest {
|
|||||||
assertNull(vm.pendingCrash.value)
|
assertNull(vm.pendingCrash.value)
|
||||||
assertNull(store.find("c"))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user