diff --git a/.github/workflows/autoupdate.yml b/.github/workflows/autoupdate.yml index 37100f2..1577208 100644 --- a/.github/workflows/autoupdate.yml +++ b/.github/workflows/autoupdate.yml @@ -17,6 +17,8 @@ name: Auto-update PR branches on: push: branches: [main] + pull_request: + branches: [main] permissions: contents: write diff --git a/app/src/test/kotlin/org/libremail/auth/OAuthModelsTest.kt b/app/src/test/kotlin/org/libremail/auth/OAuthModelsTest.kt new file mode 100644 index 0000000..d19e153 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/auth/OAuthModelsTest.kt @@ -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}")) + } +} diff --git a/app/src/test/kotlin/org/libremail/auth/OutlookAuthManagerTest.kt b/app/src/test/kotlin/org/libremail/auth/OutlookAuthManagerTest.kt new file mode 100644 index 0000000..f7c874e --- /dev/null +++ b/app/src/test/kotlin/org/libremail/auth/OutlookAuthManagerTest.kt @@ -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().dispose() } just runs + val intent = mockk() + every { anyConstructed().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()) + + 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()).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 { authManager().exchangeToken(mockk()) } + } + + @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().dispose() } just runs + + assertFailsWith { authManager().exchangeToken(mockk()) } + } + + @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 { authManager().exchangeToken(mockk()) } + } + + @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 { authManager().exchangeToken(mockk()) } + } + + @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().dispose() } just runs + + assertFailsWith { authManager().freshGraphToken("{stored}") } + } + + @Test + fun `exchangeToken surfaces a token endpoint failure`() = runTest { + installStatics() + stubResponse(authResponseWithCode("auth-code")) + stubFailingTokenRequest() + + assertFailsWith { authManager().exchangeToken(mockk()) } + } + + @Test + fun `a refresh surfaces a token endpoint failure`() = runTest { + installStatics() + stubDeserializedAuthState(refreshToken = "rt") + stubFailingTokenRequest() + + assertFailsWith { 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()?.length ?: 0) == 0 } + every { TextUtils.join(any(), any>()) } answers { + secondArg>().joinToString(firstArg().toString()) + } + mockkStatic(Uri::class) + val uri = mockk(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(), any()) } answers { + java.util.Base64.getUrlDecoder().decode(firstArg()) + } + // BrowserSelector's static init builds a probe Intent; keep its fluent chain from touching stubs. + mockkConstructor(Intent::class) + every { anyConstructed().setAction(any()) } answers { self as Intent } + every { anyConstructed().addCategory(any()) } answers { self as Intent } + every { anyConstructed().setData(any()) } answers { self as Intent } + } + + /** A context whose PackageManager reports no browsers, so AuthorizationService constructs cleanly. */ + private fun androidContext(): Context { + val pm = mockk(relaxed = true) + every { pm.resolveActivity(any(), any()) } returns null + every { pm.queryIntentActivities(any(), any()) } returns emptyList() + return mockk(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().dispose() } just runs + val queue = ArrayDeque(responses.toList()) + every { anyConstructed().performTokenRequest(any(), any()) } answers { + secondArg().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().dispose() } just runs + every { anyConstructed().performTokenRequest(any(), any()) } answers { + secondArg().onTokenRequestCompleted(null, null) + } + } + + /** Makes AuthState.jsonDeserialize hand back a mock carrying [refreshToken]. */ + private fun stubDeserializedAuthState(refreshToken: String?) { + val authState = mockk(relaxed = true) + every { authState.refreshToken } returns refreshToken + every { authState.update(any(), any()) } just runs + every { authState.jsonSerializeString() } returns "{serialized}" + mockkStatic(AuthState::class) + every { AuthState.jsonDeserialize(any()) } 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 { + val payload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(JSONObject(mapOf(*claims)).toString().toByteArray()) + return "header.$payload.signature" + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt index 7cbd260..c145cd1 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt @@ -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", diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailConnectionFactoryTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailConnectionFactoryTest.kt new file mode 100644 index 0000000..5256161 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/sync/MailConnectionFactoryTest.kt @@ -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(relaxed = true) + private val outlookAuthManager = mockk() + private val settingsRepository = mockk { + 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 { 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 { factory().graphTokenFor(outlookAccount) } + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt index 1574f54..03f9beb 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt @@ -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, failingIds: Set = emptySet()): MailSyncer { + val accountDao = mockk() + coEvery { accountDao.getAll() } returns accounts + val messageDao = mockk(relaxed = true) + coEvery { messageDao.getSyncedIds(any(), any()) } returns emptyList() + coEvery { messageDao.getUnfetchedIds(any(), any()) } returns emptyList() + val imapClient = mockk() + coEvery { imapClient.fetchRecent(any(), any(), any()) } returns listOf( + FetchedMessage("1", "Ada", "ada@example.org", "Hi", 1_000L, isRead = true, isFlagged = false), + ) + val connectionFactory = mockk() + 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() + coEvery { settingsRepository.fetchPolicy() } returns FetchPolicy.ON_DEMAND + coEvery { settingsRepository.isNewMailNotificationsEnabled() } returns false + every { settingsRepository.settings } returns flowOf(AppSettings()) + val accountSettingsRepository = mockk() + 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() diff --git a/app/src/test/kotlin/org/libremail/data/sync/SendSchedulerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SendSchedulerTest.kt new file mode 100644 index 0000000..8a99dbf --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/sync/SendSchedulerTest.kt @@ -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(relaxed = true) + every { WorkManager.getInstance(any()) } returns workManager + + SendScheduler(mockk(relaxed = true)).sendNow() + + verify { + workManager.enqueueUniqueWork( + "libremail_send_outbox", + ExistingWorkPolicy.REPLACE, + any(), + ) + } + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt index a32729d..2b9361d 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt @@ -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() + private val outboxDao = mockk(relaxed = true) private val accountDao = mockk() - private val connectionFactory = mockk() - private val attachmentUriGrants = mockk() + private val smtpSender = mockk(relaxed = true) + private val graphSender = mockk(relaxed = true) + private val connectionFactory = mockk(relaxed = true) + private val attachmentUriGrants = mockk(relaxed = true) private val lazyOutbox = mockk> { every { get() } returns outboxDao } private val lazyAccount = mockk> { every { get() } returns accountDao } private val lazyConnection = mockk> { every { get() } returns connectionFactory } private val lazyGrants = mockk> { every { get() } returns attachmentUriGrants } private val cacheGuard = mockk() + 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(), - mockk(), + 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() + + 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() + 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() + 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(), any()) } 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() + + 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() + val sent = slot>() + 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() + val sent = slot>() + 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///. */ + private fun stageFile(messageId: String, index: Int, name: String, bytes: ByteArray) { + File(cacheDir, "outbox/$messageId/$index").apply { mkdirs() } + .resolve(name).writeBytes(bytes) } } diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderSendTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderSendTest.kt new file mode 100644 index 0000000..dbcb6de --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderSendTest.kt @@ -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 { + val last = AtomicReference(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 { 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 { 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 { 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 + } + }, + ) + } + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index a4c11ed..f8726cc 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -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 { 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 { 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 { client.fetchBodyMarkingSeen(params(), "INBOX", "999999") } + assertFailsWith { client.fetchBodyPeek(params(), "INBOX", "999999") } + assertFailsWith { 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 { 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(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 + } } diff --git a/app/src/test/kotlin/org/libremail/mail/ImapConnectionCacheTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapConnectionCacheTest.kt new file mode 100644 index 0000000..4e02d7e --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/ImapConnectionCacheTest.kt @@ -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() + val cache = cache { + mockk(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 { 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 { + 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(relaxed = true))) + retriesOn(StoreClosedException(mockk(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 { + 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(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") + } +}