Merge main into build-290-jacoco-scope

This commit is contained in:
Jason Ross
2026-07-04 01:22:26 -05:00
committed by GitHub
9 changed files with 634 additions and 2 deletions
@@ -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)
}
}