Merge main into build-290-jacoco-scope
This commit is contained in:
@@ -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<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
|
||||
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<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
|
||||
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("<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
|
||||
// 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 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 = "<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.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<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) ------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user