Merge branch 'main' into fix-255-crash-prompt-gating

This commit is contained in:
Jason Ross
2026-07-03 15:31:03 -05:00
committed by GitHub
11 changed files with 1285 additions and 11 deletions
+2
View File
@@ -17,6 +17,8 @@ name: Auto-update PR branches
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
/** Value-semantics cover for the two OAuth result carriers. */
class OAuthModelsTest {
@Test
fun `OAuthResult exposes its fields and value semantics`() {
val result = OAuthResult(email = "me@example.com", accessToken = "at", authStateJson = "{json}")
assertEquals("me@example.com", result.email)
assertEquals("at", result.accessToken)
assertEquals("{json}", result.authStateJson)
assertEquals(result, result.copy())
assertNotEquals(result, result.copy(accessToken = "other"))
}
@Test
fun `FreshToken defaults its expiry to null and supports copy`() {
val fresh = FreshToken(accessToken = "at", authStateJson = "{json}")
assertNull(fresh.accessTokenExpiry)
assertEquals(4_200L, fresh.copy(accessTokenExpiry = 4_200L).accessTokenExpiry)
assertEquals("at", fresh.accessToken)
assertEquals(fresh, FreshToken("at", "{json}"))
}
}
@@ -0,0 +1,286 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.text.TextUtils
import android.util.Base64
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenRequest
import net.openid.appauth.TokenResponse
import org.json.JSONObject
import org.junit.After
import org.junit.Test
import org.libremail.BuildConfig
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* Drives the Outlook/Microsoft OAuth wrapper in a pure JVM test. The AppAuth types it builds
* (requests, responses, token responses) carry data in final fields, so those are constructed for
* real; only the boundaries are faked — the Android statics AppAuth reaches for ([Uri], [Base64],
* [TextUtils], [Intent]), the browser-less [PackageManager] that lets [AuthorizationService]
* construct, and the token-endpoint call itself (its constructor is mocked, the callback invoked with
* a canned [TokenResponse]). This pins the two-resource token exchange (Exchange token minted from the
* auth-code grant), the email extraction from the id_token (with its preferred_username fallback), the
* refresh paths, and every failure branch — none of which had unit cover before.
*/
class OutlookAuthManagerTest {
@After
fun tearDown() = unmockkAll()
@Test
fun `isConfigured reflects whether a client id ships with the build`() {
installStatics()
assertEquals(BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank(), authManager().isConfigured)
}
@Test
fun `createAuthIntent builds and returns the AppAuth sign-in intent`() {
installStatics()
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
val intent = mockk<Intent>()
every { anyConstructed<AuthorizationService>().getAuthorizationRequestIntent(any()) } returns intent
assertEquals(intent, authManager().createAuthIntent())
}
@Test
fun `exchangeToken mints an Exchange token and reads the account email from the id_token`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(
tokenResponse(access = "code-access", idToken = jwt("email" to "me@example.com"), refresh = "rt"),
tokenResponse(access = "outlook-access", idToken = null, refresh = "rt"),
)
val result = authManager().exchangeToken(mockk<Intent>())
assertEquals("me@example.com", result.email)
assertEquals("outlook-access", result.accessToken) // the Exchange-scoped token, not the code one
}
@Test
fun `exchangeToken falls back to preferred_username when the email claim is blank`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(
tokenResponse("code-access", jwt("email" to "", "preferred_username" to "alt@example.com"), "rt"),
tokenResponse("outlook-access", null, "rt"),
)
assertEquals("alt@example.com", authManager().exchangeToken(mockk<Intent>()).email)
}
@Test
fun `exchangeToken throws when the authorization was cancelled`() = runTest {
installStatics()
mockkStatic(AuthorizationResponse::class)
every { AuthorizationResponse.fromIntent(any()) } returns null
mockkStatic(AuthorizationException::class)
every { AuthorizationException.fromIntent(any()) } returns null
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `exchangeToken throws when no authorization code was returned`() = runTest {
installStatics()
stubResponse(authResponseWithCode(null)) // a response with no code
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `exchangeToken throws when the id_token carries no usable email`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(tokenResponse("code-access", jwt(), "rt")) // empty claims -> no email
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `exchangeToken throws on an unparseable id_token`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(tokenResponse("code-access", idToken = "not-a-jwt", refresh = "rt"))
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `freshOutlookToken refreshes and returns the Exchange access token`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = "rt")
stubTokenRequests(tokenResponse("exchange-at", idToken = null, refresh = "rt"))
val fresh = authManager().freshOutlookToken("{stored}")
assertEquals("exchange-at", fresh.accessToken)
assertEquals("{serialized}", fresh.authStateJson)
}
@Test
fun `freshGraphToken refreshes and returns the Graph access token`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = "rt")
stubTokenRequests(tokenResponse("graph-at", idToken = null, refresh = "rt"))
assertEquals("graph-at", authManager().freshGraphToken("{stored}").accessToken)
}
@Test
fun `a refresh without a stored refresh token asks the user to sign in again`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = null)
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
assertFailsWith<IllegalStateException> { authManager().freshGraphToken("{stored}") }
}
@Test
fun `exchangeToken surfaces a token endpoint failure`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubFailingTokenRequest()
assertFailsWith<Exception> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `a refresh surfaces a token endpoint failure`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = "rt")
stubFailingTokenRequest()
assertFailsWith<Exception> { authManager().freshGraphToken("{stored}") }
}
// --- test fixtures -----------------------------------------------------------------------------
private fun authManager() = OutlookAuthManager(androidContext())
/** Installs the Android statics AppAuth touches so its real objects can be built off-device. */
private fun installStatics() {
mockkStatic(TextUtils::class)
every { TextUtils.isEmpty(any()) } answers { (firstArg<CharSequence?>()?.length ?: 0) == 0 }
every { TextUtils.join(any(), any<Iterable<*>>()) } answers {
secondArg<Iterable<*>>().joinToString(firstArg<CharSequence>().toString())
}
mockkStatic(Uri::class)
val uri = mockk<Uri>(relaxed = true)
every { uri.scheme } returns "org.libremail"
every { Uri.parse(any()) } returns uri
every { Uri.fromParts(any(), any(), any()) } returns uri
mockkStatic(Base64::class)
every { Base64.encodeToString(any(), any()) } answers {
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(firstArg())
}
every { Base64.decode(any<String>(), any()) } answers {
java.util.Base64.getUrlDecoder().decode(firstArg<String>())
}
// BrowserSelector's static init builds a probe Intent; keep its fluent chain from touching stubs.
mockkConstructor(Intent::class)
every { anyConstructed<Intent>().setAction(any()) } answers { self as Intent }
every { anyConstructed<Intent>().addCategory(any()) } answers { self as Intent }
every { anyConstructed<Intent>().setData(any()) } answers { self as Intent }
}
/** A context whose PackageManager reports no browsers, so AuthorizationService constructs cleanly. */
private fun androidContext(): Context {
val pm = mockk<PackageManager>(relaxed = true)
every { pm.resolveActivity(any(), any<Int>()) } returns null
every { pm.queryIntentActivities(any(), any<Int>()) } returns emptyList()
return mockk<Context>(relaxed = true).also {
every { it.packageManager } returns pm
every { it.applicationContext } returns it
}
}
private val config get() = AuthorizationServiceConfiguration(Uri.parse("authorize"), Uri.parse("token"))
private fun stubResponse(response: AuthorizationResponse) {
mockkStatic(AuthorizationResponse::class)
every { AuthorizationResponse.fromIntent(any()) } returns response
mockkStatic(AuthorizationException::class)
every { AuthorizationException.fromIntent(any()) } returns null
}
private fun authResponseWithCode(code: String?): AuthorizationResponse {
val request = AuthorizationRequest.Builder(config, "client", ResponseTypeValues.CODE, Uri.parse("redirect"))
.setScope("openid")
.build()
return AuthorizationResponse.Builder(request).apply { code?.let { setAuthorizationCode(it) } }.build()
}
/** Mocks the token-endpoint call, handing back [responses] in order to each performTokenRequest. */
private fun stubTokenRequests(vararg responses: TokenResponse) {
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
val queue = ArrayDeque(responses.toList())
every { anyConstructed<AuthorizationService>().performTokenRequest(any(), any()) } answers {
secondArg<AuthorizationService.TokenResponseCallback>().onTokenRequestCompleted(queue.removeFirst(), null)
}
}
/** Mocks the token endpoint to report failure (null response, null exception) to its callback. */
private fun stubFailingTokenRequest() {
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
every { anyConstructed<AuthorizationService>().performTokenRequest(any(), any()) } answers {
secondArg<AuthorizationService.TokenResponseCallback>().onTokenRequestCompleted(null, null)
}
}
/** Makes AuthState.jsonDeserialize hand back a mock carrying [refreshToken]. */
private fun stubDeserializedAuthState(refreshToken: String?) {
val authState = mockk<AuthState>(relaxed = true)
every { authState.refreshToken } returns refreshToken
every { authState.update(any<TokenResponse>(), any()) } just runs
every { authState.jsonSerializeString() } returns "{serialized}"
mockkStatic(AuthState::class)
every { AuthState.jsonDeserialize(any<String>()) } returns authState
}
private fun tokenResponse(access: String, idToken: String?, refresh: String?): TokenResponse {
val request = TokenRequest.Builder(config, "client")
.setGrantType("authorization_code")
.setAuthorizationCode("code")
.setRedirectUri(Uri.parse("redirect"))
.build()
return TokenResponse.Builder(request)
.setAccessToken(access)
.setIdToken(idToken)
.setRefreshToken(refresh)
.setAccessTokenExpirationTime(System.currentTimeMillis() + 3_600_000L)
.build()
}
private fun jwt(vararg claims: Pair<String, String>): String {
val payload = java.util.Base64.getUrlEncoder().withoutPadding()
.encodeToString(JSONObject(mapOf(*claims)).toString().toByteArray())
return "header.$payload.signature"
}
}
@@ -332,6 +332,27 @@ class MailBackfillerTest {
coVerify(exactly = 0) { imapClient.fetchOlderThan(any(), any(), match { it <= 1L }, any()) }
}
/**
* A backfilled page whose ids already exist (e.g. former search-only rows) must be *refreshed*
* (markSynced + header update), not just IGNORE-inserted — this covers persistBatch's
* pre-existing-row branch, which the all-brand-new happy paths above never hit.
*/
@Test
fun `re-inserting a pre-existing header refreshes it rather than only inserting`() = runTest {
appendMessages(60)
seedForegroundWindow()
val backfiller = backfiller(AccountSettings("acct"))
// Report every offered id as already present, so persistBatch takes the refresh branch.
coEvery { lastMessageDao!!.existingIds(any()) } answers { firstArg() }
backfiller.runBackfill()
coVerify(atLeast = 1) { lastMessageDao!!.markSynced(any()) }
coVerify(atLeast = 1) {
lastMessageDao!!.updateHeaderContent(any(), any(), any(), any(), any(), any())
}
}
private fun fetchedMessage(uid: String) = FetchedMessage(
uid = uid,
sender = "Sender",
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import io.mockk.coEvery
import io.mockk.coVerify
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.auth.FreshToken
import org.libremail.auth.OutlookAuthManager
import org.libremail.data.security.CredentialStore
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ServerConfig
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [MailConnectionFactory] turns a stored credential into connection params, refreshing (and caching)
* OAuth access tokens on demand. These tests pin the password path, the OAuth refresh-and-cache
* behaviour (a still-valid token is reused; an expired or unknown-expiry one is refreshed), the
* persist-only-when-changed rule, and the missing-credential errors — all without a real network.
*/
class MailConnectionFactoryTest {
private val credentialStore = mockk<CredentialStore>(relaxed = true)
private val outlookAuthManager = mockk<OutlookAuthManager>()
private val settingsRepository = mockk<SettingsRepository> {
every { settings } returns flowOf(AppSettings())
}
private fun factory() = MailConnectionFactory(credentialStore, outlookAuthManager, settingsRepository)
private val passwordAccount = Account(
id = "acct",
email = "a@example.org",
displayName = "A",
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
)
private val outlookAccount = Account.outlook("me@example.com")
private fun token(access: String, json: String, expiry: Long?) =
FreshToken(accessToken = access, authStateJson = json, accessTokenExpiry = expiry)
private val future get() = System.currentTimeMillis() + 3_600_000L
@Test
fun `password imapParams resolve the stored secret and never use XOAUTH2`() = runTest {
coEvery { credentialStore.loadSecret("acct") } returns "app-password"
val params = factory().imapParamsFor(passwordAccount)
assertEquals("app-password", params.secret)
assertEquals("a@example.org", params.username)
assertFalse(params.useXoauth2)
assertTrue(params.strictStartTls) // allowStartTls defaults false -> strict
}
@Test
fun `password smtpParams resolve the stored secret`() = runTest {
coEvery { credentialStore.loadSecret("acct") } returns "app-password"
val params = factory().smtpParamsFor(passwordAccount)
assertEquals("app-password", params.secret)
assertFalse(params.useXoauth2)
}
@Test
fun `a missing password credential is a hard error`() = runTest {
coEvery { credentialStore.loadSecret("acct") } returns null
assertFailsWith<IllegalStateException> { factory().imapParamsFor(passwordAccount) }
}
@Test
fun `allowing STARTTLS relaxes the strict flag`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(allowStartTls = true))
coEvery { credentialStore.loadSecret("acct") } returns "pw"
assertFalse(factory().imapParamsFor(passwordAccount).strictStartTls)
}
@Test
fun `outlook imapParams mint an Exchange token and mark XOAUTH2`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "refreshed", future)
val params = factory().imapParamsFor(outlookAccount)
assertEquals("outlook-at", params.secret)
assertTrue(params.useXoauth2)
// The AuthState changed, so the refreshed one is persisted for next time.
coVerify { credentialStore.saveSecret(outlookAccount.id, "refreshed") }
}
@Test
fun `an unchanged AuthState is not re-persisted`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", future)
factory().imapParamsFor(outlookAccount)
coVerify(exactly = 0) { credentialStore.saveSecret(any(), any()) }
}
@Test
fun `a still-valid cached token is reused without a second refresh`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", future)
val factory = factory()
factory.imapParamsFor(outlookAccount)
val second = factory.imapParamsFor(outlookAccount)
assertEquals("outlook-at", second.secret)
coVerify(exactly = 1) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `an expired cached token forces a refresh`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
val past = System.currentTimeMillis() - 1_000L
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", past)
val factory = factory()
factory.imapParamsFor(outlookAccount)
factory.imapParamsFor(outlookAccount)
coVerify(exactly = 2) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `an unknown expiry is never trusted from cache`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", null)
val factory = factory()
factory.imapParamsFor(outlookAccount)
factory.imapParamsFor(outlookAccount)
coVerify(exactly = 2) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `graphToken mints a Graph-scoped token cached separately from the Exchange one`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshGraphToken("stored") } returns token("graph-at", "stored", future)
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", future)
val factory = factory()
assertEquals("graph-at", factory.graphTokenFor(outlookAccount))
// The Exchange scope has its own cache slot, so it still refreshes independently.
assertEquals("outlook-at", factory.imapParamsFor(outlookAccount).secret)
coVerify(exactly = 1) { outlookAuthManager.freshGraphToken(any()) }
coVerify(exactly = 1) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `a missing OAuth credential is a hard error`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns null
assertFailsWith<IllegalStateException> { factory().graphTokenFor(outlookAccount) }
}
}
@@ -28,7 +28,9 @@ import org.libremail.mail.ImapClient
import org.libremail.notifications.MailNotifier
import org.libremail.power.BatteryStatus
import org.libremail.power.BatteryStatusProvider
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MailSyncerTest {
@@ -257,6 +259,88 @@ class MailSyncerTest {
)
}
@Test
fun `syncAll succeeds with a zero total when there are no accounts`() = runTest {
val syncer = syncAllSyncer(accounts = emptyList())
val result = syncer.syncAll()
assertEquals(0, result.getOrNull())
}
@Test
fun `syncAll syncs every account inbox and sums the fetched counts`() = runTest {
val syncer = syncAllSyncer(accounts = listOf(accountEntity("one"), accountEntity("two")))
val result = syncer.syncAll()
assertTrue(result.isSuccess)
assertEquals(2, result.getOrNull()) // one message fetched per account
}
@Test
fun `syncAll still succeeds when one account fails but another syncs`() = runTest {
val syncer = syncAllSyncer(
accounts = listOf(accountEntity("ok"), accountEntity("bad")),
failingIds = setOf("bad"),
)
val result = syncer.syncAll()
assertTrue(result.isSuccess, "at least one account synced")
assertEquals(1, result.getOrNull())
}
@Test
fun `syncAll fails only when every account fails`() = runTest {
val syncer = syncAllSyncer(
accounts = listOf(accountEntity("bad1"), accountEntity("bad2")),
failingIds = setOf("bad1", "bad2"),
)
assertTrue(syncer.syncAll().isFailure)
}
private fun accountEntity(id: String) = account.copy(id = id, email = "$id@example.org")
/**
* A syncer for the [MailSyncer.syncAll] path: [accountDao.getAll] returns [accounts], each inbox
* fetch yields one message (so a successful account contributes 1 to the total), and any account
* in [failingIds] fails its connection so its per-account sync errors out.
*/
private fun syncAllSyncer(accounts: List<AccountEntity>, failingIds: Set<String> = emptySet()): MailSyncer {
val accountDao = mockk<AccountDao>()
coEvery { accountDao.getAll() } returns accounts
val messageDao = mockk<MessageDao>(relaxed = true)
coEvery { messageDao.getSyncedIds(any(), any()) } returns emptyList()
coEvery { messageDao.getUnfetchedIds(any(), any()) } returns emptyList()
val imapClient = mockk<ImapClient>()
coEvery { imapClient.fetchRecent(any(), any(), any()) } returns listOf(
FetchedMessage("1", "Ada", "ada@example.org", "Hi", 1_000L, isRead = true, isFlagged = false),
)
val connectionFactory = mockk<MailConnectionFactory>()
coEvery { connectionFactory.imapParamsFor(match { it.id in failingIds }) } throws IOException("no network")
coEvery { connectionFactory.imapParamsFor(match { it.id !in failingIds }) } returns mockk()
val settingsRepository = mockk<SettingsRepository>()
coEvery { settingsRepository.fetchPolicy() } returns FetchPolicy.ON_DEMAND
coEvery { settingsRepository.isNewMailNotificationsEnabled() } returns false
every { settingsRepository.settings } returns flowOf(AppSettings())
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
return MailSyncer(
context = mockk(relaxed = true),
accountDao = accountDao,
messageDao = messageDao,
imapClient = imapClient,
connectionFactory = connectionFactory,
settingsRepository = settingsRepository,
accountSettingsRepository = accountSettingsRepository,
batteryStatusProvider = batteryProvider(BatteryStatus(percent = 100, isCharging = false)),
notifier = mockk(relaxed = true),
mailRepository = mockk(relaxed = true),
)
}
/** A context whose active network reports the given metered state via ConnectivityManager. */
private fun networkContext(unmetered: Boolean): Context {
val capabilities = mockk<NetworkCapabilities>()
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Test
/**
* [SendScheduler] is a thin wrapper over WorkManager; this pins the behaviour that carries meaning —
* the outbox drain is enqueued as a single unique job with REPLACE, so a newly-queued message (or a
* manual retry) starts a fresh drain rather than waiting behind a pending backoff.
*/
class SendSchedulerTest {
@After
fun tearDown() = unmockkAll()
@Test
fun `sendNow enqueues a unique send-outbox job with REPLACE`() {
mockkObject(WorkManager.Companion)
val workManager = mockk<WorkManager>(relaxed = true)
every { WorkManager.getInstance(any()) } returns workManager
SendScheduler(mockk(relaxed = true)).sendNow()
verify {
workManager.enqueueUniqueWork(
"libremail_send_outbox",
ExistingWorkPolicy.REPLACE,
any<OneTimeWorkRequest>(),
)
}
}
}
@@ -1,52 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker.Result
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.attachment.AttachmentUriGrants
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.local.toOutgoingAttachmentsJson
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.SmtpParams
import org.libremail.mail.GraphSendException
import org.libremail.mail.GraphSender
import org.libremail.mail.SendableAttachment
import org.libremail.mail.SmtpSender
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [SendWorker] already gates on [EncryptedCacheGuard]; this locks that invariant in — while the cache
* is locked it must defer without resolving any of its (`Lazy`) DB-backed dependencies (resolving any
* of them opens the Room DB, which blocks on the passphrase await).
* [SendWorker] first gates on [EncryptedCacheGuard] (regression cover for the pre-auth-DB class of
* bug — while locked it must defer without resolving any of its `Lazy` DB-backed deps), then drains
* the outbox: sending each queued message over SMTP or Microsoft Graph, deleting it on success,
* flagging failures for a retry, and — crucially for the "may have sent" case — never auto-retrying
* a Graph request that might already have delivered.
*/
class SendWorkerTest {
private val outboxDao = mockk<OutboxDao>()
private val outboxDao = mockk<OutboxDao>(relaxed = true)
private val accountDao = mockk<AccountDao>()
private val connectionFactory = mockk<MailConnectionFactory>()
private val attachmentUriGrants = mockk<AttachmentUriGrants>()
private val smtpSender = mockk<SmtpSender>(relaxed = true)
private val graphSender = mockk<GraphSender>(relaxed = true)
private val connectionFactory = mockk<MailConnectionFactory>(relaxed = true)
private val attachmentUriGrants = mockk<AttachmentUriGrants>(relaxed = true)
private val lazyOutbox = mockk<Lazy<OutboxDao>> { every { get() } returns outboxDao }
private val lazyAccount = mockk<Lazy<AccountDao>> { every { get() } returns accountDao }
private val lazyConnection = mockk<Lazy<MailConnectionFactory>> { every { get() } returns connectionFactory }
private val lazyGrants = mockk<Lazy<AttachmentUriGrants>> { every { get() } returns attachmentUriGrants }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private lateinit var cacheDir: File
private lateinit var appContext: Context
@Before
fun setUp() {
cacheDir = java.nio.file.Files.createTempDirectory("sendworker").toFile()
appContext = mockk(relaxed = true)
every { appContext.cacheDir } returns cacheDir
coEvery { cacheGuard.isCacheLocked() } returns false
}
@After
fun tearDown() {
cacheDir.deleteRecursively()
unmockkAll()
}
private fun worker() = SendWorker(
mockk(relaxed = true),
appContext,
mockk(relaxed = true),
lazyOutbox,
lazyAccount,
mockk<SmtpSender>(),
mockk<GraphSender>(),
smtpSender,
graphSender,
lazyConnection,
cacheGuard,
lazyGrants,
)
private fun account(id: String, authType: String) = AccountEntity(
id = id,
email = "$id@example.org",
displayName = id,
authType = authType,
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
)
private fun entity(id: String = "m1", accountId: String = "acct", attachments: String = "") = OutboxEntity(
id = id,
accountId = accountId,
toAddresses = "bob@example.org",
ccAddresses = "",
subject = "Hi",
body = "Body",
createdAt = 0L,
attachments = attachments,
)
@Test
fun `retries without resolving any DB dependency when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
@@ -60,12 +119,154 @@ class SendWorkerTest {
}
@Test
fun `drains the outbox when the cache is unlocked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
fun `an empty outbox succeeds without sending`() = runTest {
coEvery { outboxDao.getAll() } returns emptyList()
assertEquals(Result.success(), worker().doWork())
coVerify(exactly = 1) { outboxDao.getAll() }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) }
}
@Test
fun `drops a queued message whose account was removed`() = runTest {
val row = entity()
coEvery { outboxDao.getAll() } returns listOf(row)
coEvery { accountDao.getById("acct") } returns null
assertEquals(Result.success(), worker().doWork())
coVerify { outboxDao.delete("m1") }
coVerify { attachmentUriGrants.releaseUnreferenced(any()) }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) }
}
@Test
fun `sends a password account message over SMTP then clears it`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
assertEquals(Result.success(), worker().doWork())
coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) }
coVerify { outboxDao.delete("m1") }
coVerify { attachmentUriGrants.releaseUnreferenced(any()) }
}
@Test
fun `an SMTP failure flags the row and retries`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
coEvery { smtpSender.send(any(), any(), any(), any()) } throws RuntimeException("smtp down")
assertEquals(Result.retry(), worker().doWork())
coVerify { outboxDao.setError("m1", "smtp down") }
coVerify(exactly = 0) { outboxDao.delete(any()) }
}
@Test
fun `sends an Outlook message over Graph`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } returns "graph-token"
assertEquals(Result.success(), worker().doWork())
coVerify { graphSender.send("graph-token", any(), any()) }
coVerify { outboxDao.delete("m1") }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) }
}
@Test
fun `a Graph send that may have sent is left queued and not retried`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } returns "graph-token"
coEvery { graphSender.send(any(), any(), any()) } throws
GraphSendException("maybe sent", mayHaveSent = true)
// Not a failure: WorkManager must NOT auto-retry, or the message could be duplicated.
assertEquals(Result.success(), worker().doWork())
coVerify { outboxDao.setError("m1", match { it.contains("check your Sent folder") }) }
coVerify(exactly = 0) { outboxDao.delete(any()) }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) } // must not fall back
}
@Test
fun `a Graph rejection falls back to SMTP`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } returns "graph-token"
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
coEvery { graphSender.send(any(), any(), any()) } throws
GraphSendException("rejected", mayHaveSent = false)
assertEquals(Result.success(), worker().doWork())
coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) }
coVerify { outboxDao.delete("m1") }
}
@Test
fun `a Graph transport error falls back to SMTP`() = runTest {
mockkStatic(Log::class)
every { Log.w(any(), any<String>(), any<Throwable>()) } returns 0
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } throws RuntimeException("token refresh failed")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
assertEquals(Result.success(), worker().doWork())
coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) }
coVerify { outboxDao.delete("m1") }
}
@Test
fun `stages attachments pairing an inline image with its file and content id`() = runTest {
val attachmentsJson = listOf(
OutgoingAttachment(uri = "content://1", name = "logo.png", contentId = "logo@x", isInline = true),
OutgoingAttachment(uri = "content://2", name = "doc.pdf"),
).toOutgoingAttachmentsJson()
stageFile("m1", index = 0, name = "logo.png", bytes = byteArrayOf(1, 2))
stageFile("m1", index = 1, name = "doc.pdf", bytes = byteArrayOf(3, 4))
coEvery { outboxDao.getAll() } returns listOf(entity(attachments = attachmentsJson))
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
val sent = slot<List<SendableAttachment>>()
coEvery { smtpSender.send(any(), any(), any(), capture(sent)) } returns Unit
worker().doWork()
assertEquals(2, sent.captured.size)
val inline = sent.captured.single { it.isInline }
assertEquals("logo@x", inline.contentId)
assertTrue(sent.captured.any { !it.isInline })
}
@Test
fun `falls back to positional staged files when there is no attachment metadata`() = runTest {
stageFile("m1", index = 0, name = "a.txt", bytes = byteArrayOf(1))
stageFile("m1", index = 1, name = "b.txt", bytes = byteArrayOf(2))
coEvery { outboxDao.getAll() } returns listOf(entity(attachments = ""))
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
val sent = slot<List<SendableAttachment>>()
coEvery { smtpSender.send(any(), any(), any(), capture(sent)) } returns Unit
worker().doWork()
assertEquals(2, sent.captured.size)
assertFalse(sent.captured.any { it.isInline }) // positional restore is always plain attachments
}
/** Stages a file the way the compose pipeline does: cacheDir/outbox/<id>/<index>/<name>. */
private fun stageFile(messageId: String, index: Int, name: String, bytes: ByteArray) {
File(cacheDir, "outbox/$messageId/$index").apply { mkdirs() }
.resolve(name).writeBytes(bytes)
}
}
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Test
import org.libremail.domain.model.OutgoingMessage
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import java.net.URLStreamHandler
import java.net.URLStreamHandlerFactory
import java.util.concurrent.atomic.AtomicReference
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Exercises the [GraphSender.send] transport path (its JSON payload builder is unit-tested separately
* in [GraphSenderTest]). The Graph endpoint is a fixed https URL the sender news up itself, so the
* test routes https through a process-wide [URLStreamHandlerFactory] to a per-test fake connection —
* no network, no production seam. The behaviour pinned: a 2xx succeeds; a non-2xx is a safe-to-retry
* rejection; a lost response is flagged [GraphSendException.mayHaveSent] (must NOT retry/fall back);
* a transmit failure is not; and the connection is always disconnected.
*/
class GraphSenderSendTest {
@After
fun tearDown() = armed.set(null)
private val message =
OutgoingMessage(accountId = "outlook:me@x.com", to = "bob@example.org", subject = "Hi", body = "Body")
private fun arm(
status: Int = 202,
body: String = "",
failOutput: Boolean = false,
failResponse: Boolean = false,
): AtomicReference<FakeGraphConnection?> {
val last = AtomicReference<FakeGraphConnection?>(null)
armed.set { u -> FakeGraphConnection(u, status, body, failOutput, failResponse).also { last.set(it) } }
return last
}
@Test
fun `a 2xx response completes the send`() = runTest {
val last = arm(status = 202)
GraphSender().send("token", message)
assertTrue(last.get()!!.disconnected, "the connection must be disconnected when done")
}
@Test
fun `a non-2xx response is a safe-to-retry rejection`() = runTest {
arm(status = 400, body = "{\"error\":\"bad request\"}")
val ex = assertFailsWith<GraphSendException> { GraphSender().send("token", message) }
assertFalse(ex.mayHaveSent, "an explicit rejection means Graph did not send")
assertTrue(ex.message!!.contains("HTTP 400"), ex.message!!)
}
@Test
fun `a lost response is flagged as maybe-sent`() = runTest {
arm(failResponse = true)
val ex = assertFailsWith<GraphSendException> { GraphSender().send("token", message) }
assertTrue(ex.mayHaveSent, "the request was fully sent, so it may already have delivered")
}
@Test
fun `a transmit failure is not maybe-sent`() = runTest {
arm(failOutput = true)
val ex = assertFailsWith<GraphSendException> { GraphSender().send("token", message) }
assertFalse(ex.mayHaveSent, "the request never reached Graph, so a retry is safe")
}
@Test
fun `GraphSendException carries its message, flag and cause`() {
val cause = IOException("boom")
val ex = GraphSendException("failed", mayHaveSent = true, cause = cause)
assertEquals("failed", ex.message)
assertTrue(ex.mayHaveSent)
assertEquals(cause, ex.cause)
}
private class FakeGraphConnection(
url: URL,
private val status: Int,
private val body: String,
private val failOutput: Boolean,
private val failResponse: Boolean,
) : HttpURLConnection(url) {
var disconnected = false
override fun connect() = Unit
override fun disconnect() {
disconnected = true
}
override fun usingProxy() = false
override fun getOutputStream(): OutputStream =
if (failOutput) throw IOException("cannot transmit") else ByteArrayOutputStream()
override fun getResponseCode(): Int = if (failResponse) throw IOException("no response") else status
override fun getInputStream(): InputStream = body.byteInputStream()
override fun getErrorStream(): InputStream? = if (body.isEmpty()) null else body.byteInputStream()
}
companion object {
private val armed = AtomicReference<((URL) -> HttpURLConnection)?>(null)
// Set once per JVM: route https opens to whatever the running test armed. Only this test opens
// https in the unit-test JVM (ReportUploadWorker's endpoint is blank and never opened), so a
// permanent https handler is safe here.
init {
URL.setURLStreamHandlerFactory(
URLStreamHandlerFactory { protocol ->
if (protocol == "https") {
object : URLStreamHandler() {
override fun openConnection(u: URL): URLConnection =
armed.get()?.invoke(u) ?: throw IOException("no fake connection armed")
}
} else {
null
}
},
)
}
}
}
@@ -1,10 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import android.util.Log
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.GreenMailUtil
import com.icegreen.greenmail.util.ServerSetupTest
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import jakarta.activation.DataHandler
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Part
@@ -14,13 +19,20 @@ import jakarta.mail.internet.MimeBodyPart
import jakarta.mail.internet.MimeMessage
import jakarta.mail.internet.MimeMultipart
import jakarta.mail.util.ByteArrayDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import java.util.Properties
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
@@ -41,6 +53,7 @@ class ImapClientTest {
@After
fun tearDown() {
greenMail.stop()
unmockkAll()
}
private fun params(secret: String = "secret") = ImapConnectionParams(
@@ -187,6 +200,142 @@ class ImapClientTest {
assertEquals(setOf("Inbox subject"), inbox.map { it.subject }.toSet())
}
@Test
fun `fetchAttachment downloads a part's bytes by its index`() = runTest {
appendInlineImageDigest() // part 0 = inline logo.png, part 1 = invoice.pdf
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
val inline = client.fetchAttachment(params(), "INBOX", uid, 0)
val attachment = client.fetchAttachment(params(), "INBOX", uid, 1)
assertEquals("logo.png", inline.filename)
assertContentEquals(byteArrayOf(1, 2, 3, 4), inline.bytes)
assertEquals("invoice.pdf", attachment.filename)
assertContentEquals(byteArrayOf(5, 6, 7), attachment.bytes)
assertTrue(attachment.mimeType.contains("pdf", ignoreCase = true), attachment.mimeType)
}
@Test
fun `fetchAttachment fails for an out-of-range part index`() = runTest {
appendInlineImageDigest()
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
assertFailsWith<Exception> { client.fetchAttachment(params(), "INBOX", uid, 99) }
}
@Test
fun `fetchAttachment fails when the message is not found`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Solo", "Body")
greenMail.waitForIncomingEmail(1)
assertFailsWith<Exception> { client.fetchAttachment(params(), "INBOX", "999999", 0) }
}
@Test
fun `setFlag marks a message flagged on the server`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Star me", "Body")
greenMail.waitForIncomingEmail(1)
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
client.setFlag(params(), "INBOX", uid, Flags.Flag.FLAGGED, value = true)
assertTrue(client.fetchRecent(params(), "INBOX", limit = 50).first().isFlagged, "should be flagged")
}
@Test
fun `setFlag on an unknown uid is a no-op`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Present", "Body")
greenMail.waitForIncomingEmail(1)
// No message has this uid, so getMessageByUID returns null and setFlag simply does nothing.
client.setFlag(params(), "INBOX", "999999", Flags.Flag.FLAGGED, value = true)
assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isFlagged)
}
@Test
fun `deleteMessage expunges the message from the folder`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Delete me", "Body")
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Keep me", "Body")
greenMail.waitForIncomingEmail(2)
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first { it.subject == "Delete me" }.uid
client.deleteMessage(params(), "INBOX", uid)
val remaining = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }
assertFalse(remaining.contains("Delete me"), "remaining=$remaining")
assertTrue(remaining.contains("Keep me"), "remaining=$remaining")
}
@Test
fun `fetchRecent returns empty for an empty folder`() = runTest {
createFolder("Empty")
assertTrue(client.fetchRecent(params(), "Empty", limit = 50).isEmpty())
}
@Test
fun `body and reply fetches fail for an unknown uid`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Present", "Body")
greenMail.waitForIncomingEmail(1)
assertFailsWith<Exception> { client.fetchBodyMarkingSeen(params(), "INBOX", "999999") }
assertFailsWith<Exception> { client.fetchBodyPeek(params(), "INBOX", "999999") }
assertFailsWith<Exception> { client.fetchForReply(params(), "INBOX", "999999") }
}
@Test
fun `STARTTLS and XOAUTH2 params drive the corresponding connection properties`() = runTest {
// GreenMail here is plaintext with no XOAUTH2, so the connect fails — but buildProps has already
// run, which is the STARTTLS + XOAUTH2 property wiring this exercises.
val params = ImapConnectionParams(
host = "127.0.0.1",
port = greenMail.imap.port,
security = MailSecurity.STARTTLS,
username = "alice@example.org",
secret = "secret",
useXoauth2 = true,
strictStartTls = true,
)
assertFailsWith<Exception> { client.listFolders(params) }
}
@Test
fun `idle syncs once on connect, again on newly delivered mail, and stops on cancel`() = runBlocking {
mockkStatic(Log::class) // idle() logs connect/push at debug level
every { Log.d(any(), any()) } returns 0
val activity = Channel<Unit>(Channel.UNLIMITED)
val job = launch(Dispatchers.IO) { client.idle(params()) { activity.send(Unit) } }
try {
// A sync fires immediately on connect to catch anything already waiting...
withTimeout(IDLE_TIMEOUT_MS) { activity.receive() }
// ...then an IMAP IDLE push fires another when new mail arrives.
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Pushed", "Body")
withTimeout(IDLE_TIMEOUT_MS) { activity.receive() }
} finally {
job.cancelAndJoin() // cancelling closes the connection and unblocks idle()
}
assertTrue(job.isCompleted, "the idle loop must terminate on cancellation")
}
/** Creates [folderName] (holding messages) if it does not already exist. */
private fun createFolder(folderName: String) {
val props = Properties().apply {
put("mail.store.protocol", "imap")
put("mail.imap.host", "127.0.0.1")
put("mail.imap.port", greenMail.imap.port.toString())
}
val store = Session.getInstance(props).getStore("imap")
store.connect("127.0.0.1", greenMail.imap.port, "alice@example.org", "secret")
try {
val folder = store.getFolder(folderName)
if (!folder.exists()) folder.create(Folder.HOLDS_MESSAGES)
} finally {
store.close()
}
}
/**
* Appends a rich digest to the INBOX: a `multipart/mixed` of a `multipart/related` (HTML body
* referencing an inline image via `cid:logo1`) plus a genuine PDF attachment — the shape that
@@ -275,4 +424,9 @@ class ImapClientTest {
store.close()
}
}
private companion object {
/** Generous ceiling for the in-process IDLE round trip so the assertion never races the server. */
const val IDLE_TIMEOUT_MS = 15_000L
}
}
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import io.mockk.mockk
import io.mockk.verify
import jakarta.mail.Folder
import jakarta.mail.FolderClosedException
import jakarta.mail.MessagingException
import jakarta.mail.Store
import jakarta.mail.StoreClosedException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* The connection-reuse cache (issue #125 spike): one authenticated [Store] per account behind a
* mutex, established lazily and kept open. These tests pin the reuse guarantee and the lazy
* catch-and-retry-once stale handling — a dropped connection is rebuilt and the op retried, a second
* failure clears the slot, and a genuine protocol error is propagated without ever reconnecting.
*/
class ImapConnectionCacheTest {
private val params = ImapConnectionParams(
host = "127.0.0.1",
port = 143,
security = MailSecurity.NONE,
username = "alice@example.org",
secret = "secret",
useXoauth2 = false,
)
private var connects = 0
/** A cache whose connect step counts calls and returns [supply] (a fresh relaxed [Store] by default). */
private fun cache(supply: () -> Store = { mockk(relaxed = true) }) = ImapConnectionCache {
connects++
supply()
}
@Test
fun `establishes one connection and reuses it across calls`() = runTest {
val cache = cache()
assertEquals("a", cache.withStore(params) { "a" })
assertEquals("b", cache.withStore(params) { "b" })
assertEquals(1, connects, "the second op reuses the first connection")
}
@Test
fun `rebuilds the socket once and retries when the connection drops`() = runTest {
val opened = mutableListOf<Store>()
val cache = cache {
mockk<Store>(relaxed = true).also { opened += it }
}
var attempts = 0
val result = cache.withStore(params) {
attempts++
if (attempts == 1) throw IOException("dropped") else "recovered"
}
assertEquals("recovered", result)
assertEquals(2, connects, "a dropped connection is rebuilt exactly once")
verify { opened[0].close() } // the stale socket is torn down before reconnecting
}
@Test
fun `a second failure after reconnect clears the slot so the next call reconnects`() = runTest {
val cache = cache()
assertFailsWith<IOException> { cache.withStore(params) { throw IOException("still down") } }
assertEquals(2, connects, "initial connect plus one rebuild")
cache.withStore(params) { "ok" }
assertEquals(3, connects, "the cleared slot forces a fresh connect")
}
@Test
fun `a genuine protocol error is propagated without reconnecting`() = runTest {
val cache = cache()
assertFailsWith<IllegalStateException> {
cache.withStore(params) { throw IllegalStateException("bad login") }
}
assertEquals(1, connects, "a non-drop error must not trigger a reconnect")
}
@Test
fun `folder-closed, store-closed, IO and IO-caused messaging errors all count as drops`() = runTest {
retriesOn(FolderClosedException(mockk<Folder>(relaxed = true)))
retriesOn(StoreClosedException(mockk<Store>(relaxed = true)))
retriesOn(IOException("socket"))
retriesOn(MessagingException("wrapped", IOException("socket")))
}
@Test
fun `a messaging error without an IO cause is not a drop`() = runTest {
val cache = cache()
assertFailsWith<MessagingException> {
cache.withStore(params) { throw MessagingException("server said no") }
}
assertEquals(1, connects)
}
@Test
fun `closeAll tears down and forgets every cached connection`() = runTest {
val store = mockk<Store>(relaxed = true)
val cache = cache { store }
cache.withStore(params) { "a" }
cache.closeAll()
verify { store.close() }
cache.withStore(params) { "b" }
assertEquals(2, connects, "after closeAll the next op reconnects")
}
/** Asserts [error] is treated as a dropped connection: rebuilt once, the retry succeeds. */
private suspend fun retriesOn(error: Throwable) {
connects = 0
val cache = cache()
var attempts = 0
val result = cache.withStore(params) {
attempts++
if (attempts == 1) throw error else "ok"
}
assertEquals("ok", result)
assertEquals(2, connects, "${error.javaClass.simpleName} should have been retried on a fresh socket")
}
}