Merge branch 'main' into fix-255-crash-prompt-gating
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user