Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3256e036d1 | ||
|
|
46ca019aa8 | ||
|
|
0045890148 | ||
|
|
17985cb04c |
@@ -1,246 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "553c7a19d228b3ba8f8a750232b4c7d9",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL DEFAULT 0, `authError` TEXT, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "authError",
|
||||
"columnName": "authError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionCount",
|
||||
"columnName": "retentionCount",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionMonths",
|
||||
"columnName": "retentionMonths",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '553c7a19d228b3ba8f8a750232b4c7d9')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -86,25 +86,4 @@ class AccountDaoTest {
|
||||
|
||||
assertNull(dao.getById("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setAuthErrorStampsTheMessageIdempotentlyAndAReAddClearsIt() = runBlocking {
|
||||
dao.upsert(account("acct", "ada@example.org"))
|
||||
assertNull("a fresh account carries no error", dao.getById("acct")?.authError)
|
||||
|
||||
// The conditional UPDATE stamps the message and reports one row changed (issue #362)...
|
||||
assertEquals(1, dao.setAuthError("acct", MESSAGE))
|
||||
assertEquals(MESSAGE, dao.getById("acct")?.authError)
|
||||
// ...and is idempotent: re-writing the same message changes nothing (so the caller logs once).
|
||||
assertEquals(0, dao.setAuthError("acct", MESSAGE))
|
||||
|
||||
// A re-add rewrites the row from a fresh (null-authError) entity, clearing the error — the
|
||||
// clear-on-re-add path AccountRepositoryImpl relies on (insertAtEnd's in-place update).
|
||||
dao.upsert(account("acct", "ada@example.org"))
|
||||
assertNull("re-adding the account clears the persisted error", dao.getById("acct")?.authError)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MESSAGE = "Please remove and re-add this account with valid credentials"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ class AccountDataMigratorTest {
|
||||
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
|
||||
val schema = JSONObject(
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("org.libremail.data.local.AccountDatabase/3.json")
|
||||
.open(latestAccountDatabaseSchemaAsset())
|
||||
.bufferedReader().use { it.readText() },
|
||||
).getJSONObject("database")
|
||||
val entities = schema.getJSONArray("entities")
|
||||
@@ -312,4 +312,28 @@ class AccountDataMigratorTest {
|
||||
)
|
||||
assertTrue("the signatures index must be present in the exported schema", checkedIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest-versioned exported AccountDatabase schema shipped as an androidTest asset — i.e. the
|
||||
* current schema. Resolved dynamically so a schema-version bump is validated automatically and no
|
||||
* one has to remember to bump a hardcoded filename here (issue #477). Fails loudly if the schema
|
||||
* assets are missing/mislocated rather than silently validating against nothing.
|
||||
*/
|
||||
private fun latestAccountDatabaseSchemaAsset(): String {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.assets
|
||||
val latest = assets.list(ACCOUNT_DATABASE_SCHEMA_DIR).orEmpty()
|
||||
.filter { it.endsWith(".json") }
|
||||
.mapNotNull { it.removeSuffix(".json").toIntOrNull() }
|
||||
.maxOrNull()
|
||||
assertNotNull(
|
||||
"No exported AccountDatabase schema JSON under assets/$ACCOUNT_DATABASE_SCHEMA_DIR — is " +
|
||||
"schemas/ still wired as an androidTest asset srcDir in app/build.gradle.kts?",
|
||||
latest,
|
||||
)
|
||||
return "$ACCOUNT_DATABASE_SCHEMA_DIR/$latest.json"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ACCOUNT_DATABASE_SCHEMA_DIR = "org.libremail.data.local.AccountDatabase"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,28 +63,6 @@ class AccountMigrationTest {
|
||||
db.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 -> v3 (issue #362): a nullable `accounts.authError` appears, and existing accounts migrate to NULL
|
||||
* ("healthy — no error"). runMigrationsAndValidate confirms the resulting schema matches the exported v3
|
||||
* JSON, so any drift between the plain ADD COLUMN and the entity would fail here, not at a user's first
|
||||
* open.
|
||||
*/
|
||||
@Test
|
||||
fun migrate2To3_addsNullableAuthErrorDefaultingToNull() {
|
||||
helper.createDatabase(TEST_DB, 2).apply {
|
||||
insertAccount("a", "ada@example.org")
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 3, true, ACCOUNT_MIGRATION_2_3)
|
||||
|
||||
db.query("SELECT authError FROM accounts WHERE id = 'a'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertTrue("an existing account migrates to a null (healthy) authError", c.isNull(0))
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.insertAccount(id: String, email: String) {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
|
||||
+54
@@ -3,6 +3,9 @@ package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -15,7 +18,9 @@ import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
@@ -27,7 +32,11 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.security.DatabaseKeyCipher
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.KeystoreCrypto
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.security.SealState
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
@@ -72,6 +81,8 @@ class DatabaseProvisionerInstrumentedTest {
|
||||
clean()
|
||||
coEvery { keyStore.isClearPending() } returns false
|
||||
coEvery { keyStore.resolvePassphrase(any()) } returns passphrase
|
||||
coEvery { keyStore.hasAuthSealedPassphrase() } returns false
|
||||
coEvery { keyStore.sealWithMaster() } just Runs
|
||||
coEvery { migrator.migrateIfNeeded() } just Runs
|
||||
}
|
||||
|
||||
@@ -186,6 +197,49 @@ class DatabaseProvisionerInstrumentedTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #479, on real collaborators end to end: the transitional window — encryptCache already
|
||||
* OFF, the cache still SQLCipher-encrypted on disk, the passphrase still auth-sealed — must not
|
||||
* merely decrypt the file; it must also RELEASE the orphaned auth seal by resealing under the
|
||||
* non-auth master key. Otherwise the app stays in the window indefinitely, where losing the
|
||||
* auth-bound key (device-lock removal / re-enrollment) forces a needless cache wipe.
|
||||
*
|
||||
* Uses a REAL [DatabaseKeyStore] over a fresh test DataStore plus real Keystore crypto: the
|
||||
* sealed-auth blob only needs to EXIST (sealState reads presence), and the passphrase itself
|
||||
* comes from the unlocked [PassphraseSession] — exactly the state after a real authentication.
|
||||
*/
|
||||
@Test
|
||||
fun encryptionTurnedOffReleasesTheOrphanedAuthSealAfterDecrypting() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = true))
|
||||
seedPlaintextRow()
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("precondition: the cache starts encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
val session = PassphraseSession().apply { unlock(passphrase) }
|
||||
val realKeyStore = DatabaseKeyStore(appContext, KeystoreCrypto(), DatabaseKeyCipher(), session)
|
||||
realKeyStore.dataStore = PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
) { File(appContext.cacheDir, "provisioner_seal_test_${System.nanoTime()}.preferences_pb") }
|
||||
realKeyStore.dataStore.edit { it[stringPreferencesKey("sealed_db_key_auth")] = "sealed-by-auth-key" }
|
||||
assertTrue("precondition: the passphrase is auth-sealed", realKeyStore.hasAuthSealedPassphrase())
|
||||
|
||||
val provisioner = DatabaseProvisioner(context, realKeyStore, settingsRepository, migrator, Dispatchers.IO)
|
||||
val mode = provisioner.prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
assertFalse("the cache was decrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
// The headline: no orphaned auth seal survives the conversion — it was resealed under the
|
||||
// master key, so the passphrase stays recoverable WITHOUT authentication...
|
||||
assertFalse("the auth seal must be released", realKeyStore.hasAuthSealedPassphrase())
|
||||
assertEquals(SealState.MASTER, realKeyStore.sealState())
|
||||
// ...and a later encryptCache re-enable reuses the very same passphrase (real Keystore round-trip).
|
||||
assertEquals(passphrase, realKeyStore.passphrase())
|
||||
openPlaintext().apply {
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plaintextStartLeavesThePlaintextCacheUntouched() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
|
||||
|
||||
-2
@@ -32,7 +32,6 @@ 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 org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedFolder
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
@@ -79,7 +78,6 @@ class AccountAddCredentialOrderingInstrumentedTest {
|
||||
draftDao = mockk<DraftDao>(relaxed = true),
|
||||
credentialStore = credentialStore,
|
||||
imapClient = imapClient,
|
||||
authGate = mockk<AuthThrottleGate>(relaxed = true),
|
||||
syncScheduler = mockk<SyncScheduler>(relaxed = true),
|
||||
accountSettingsRepository = mockk<AccountSettingsRepository>(relaxed = true),
|
||||
mailNotifier = mockk<MailNotifier>(relaxed = true),
|
||||
|
||||
+96
-5
@@ -9,6 +9,7 @@ import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import dagger.Lazy
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
@@ -22,17 +23,21 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.security.SealState
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* On-device proof that [PruneWorker] and [BackfillWorker] defer (`Result.retry()`) while the encrypted
|
||||
* cache is locked, driving them with the REAL [EncryptedCacheGuard] rather than the mocked guard the JVM
|
||||
* `PruneWorkerTest`/`BackfillWorkerTest` use (issue #225). [EncryptedCacheGuard] depends only on
|
||||
* [SettingsRepository] and [PassphraseSession] — never the Keystore or `BiometricPrompt` — so the locked
|
||||
* state is reproduced here with no device auth: a fixed [SettingsRepository] (mocked the same way
|
||||
* [SettingsRepository], [DatabaseKeyStore] (its seal state — issue #479), a raw header read of the
|
||||
* cache file, and [PassphraseSession] — never the Keystore crypto or `BiometricPrompt` — so the locked
|
||||
* state is reproduced here with no device auth: fixed settings/seal collaborators (mocked the same way
|
||||
* `DatabaseProvisionerInstrumentedTest` fakes its security/settings collaborators) plus a real,
|
||||
* never-unlocked [PassphraseSession].
|
||||
*
|
||||
@@ -120,12 +125,98 @@ class WorkerCacheLockDeferralInstrumentedTest {
|
||||
assertFalse(cacheGuard.isCacheLocked())
|
||||
}
|
||||
|
||||
/** A real [EncryptedCacheGuard] over a fixed (mocked) [SettingsRepository] and the real [session]. */
|
||||
private fun guardFor(appLock: Boolean, encryptCache: Boolean): EncryptedCacheGuard {
|
||||
// --- Issue #479: the guard answers from the seal + on-disk state, not the settings pair -------
|
||||
|
||||
/**
|
||||
* Transitional case A: encryptCache was toggled OFF but the on-disk decrypt runs only at the next
|
||||
* cold start — the cache is still auth-sealed and encrypted. The settings-derived formula said
|
||||
* "unlocked" here, so a worker proceeded into `provideDatabase` and parked forever on the
|
||||
* passphrase await (holding the provisioner mutex). The real guard must report locked so the
|
||||
* worker defers with `Result.retry()` instead.
|
||||
*/
|
||||
@Test
|
||||
fun pruneWorkerDefersDuringTheEncryptCacheOffTransitionalWindow() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(
|
||||
appLock = true,
|
||||
encryptCache = false,
|
||||
sealState = SealState.AUTH,
|
||||
dbFile = encryptedFixtureFile(),
|
||||
)
|
||||
assertTrue(
|
||||
"precondition: auth-sealed + still-encrypted must report locked despite the off setting",
|
||||
cacheGuard.isCacheLocked(),
|
||||
)
|
||||
|
||||
val lazyPruner = mockk<Lazy<MailPruner>>()
|
||||
val worker = TestListenableWorkerBuilder<PruneWorker>(context)
|
||||
.setWorkerFactory(pruneWorkerFactory(lazyPruner, cacheGuard))
|
||||
.build()
|
||||
|
||||
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
|
||||
|
||||
assertEquals(Result.retry(), result)
|
||||
verify(exactly = 0) { lazyPruner.get() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitional case B: app-lock (or encryptCache) was just enabled mid-session, but the passphrase
|
||||
* is still MASTER-sealed until the next authentication reseals it — the DB opens auth-free, so
|
||||
* background sync/push/send must NOT stall on the settings pair (the old formula reported locked).
|
||||
*/
|
||||
@Test
|
||||
fun theRealGuardReportsUnlockedWhileThePassphraseIsStillMasterSealed() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(appLock = true, encryptCache = true, sealState = SealState.MASTER)
|
||||
|
||||
assertFalse(cacheGuard.isCacheLocked())
|
||||
}
|
||||
|
||||
/**
|
||||
* A lingering (orphaned) auth seal over an already-plaintext cache — the post-conversion tail of
|
||||
* case A on installs that predate the provisioner's reseal — must not stall background work: the
|
||||
* plaintext open needs no passphrase.
|
||||
*/
|
||||
@Test
|
||||
fun theRealGuardReportsUnlockedOnceTheCacheIsPlaintextDespiteALingeringAuthSeal() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(
|
||||
appLock = true,
|
||||
encryptCache = false,
|
||||
sealState = SealState.AUTH,
|
||||
dbFile = plaintextFixtureFile(),
|
||||
)
|
||||
|
||||
assertFalse(cacheGuard.isCacheLocked())
|
||||
}
|
||||
|
||||
/**
|
||||
* A real [EncryptedCacheGuard] over fixed (mocked) [SettingsRepository]/[DatabaseKeyStore]
|
||||
* collaborators, the real [session], and — for the [SealState.AUTH]-with-setting-off transitional
|
||||
* window — a real fixture file whose header decides the on-disk half of the answer.
|
||||
*/
|
||||
private fun guardFor(
|
||||
appLock: Boolean,
|
||||
encryptCache: Boolean,
|
||||
sealState: SealState = SealState.NONE,
|
||||
dbFile: File? = null,
|
||||
): EncryptedCacheGuard {
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
val settings = AppSettings(appLock = appLock, encryptCache = encryptCache)
|
||||
every { settingsRepository.settings } returns flowOf(settings)
|
||||
return EncryptedCacheGuard(settingsRepository, session)
|
||||
val keyStore = mockk<DatabaseKeyStore>()
|
||||
coEvery { keyStore.sealState() } returns sealState
|
||||
return EncryptedCacheGuard(context, settingsRepository, keyStore, session)
|
||||
.apply { if (dbFile != null) this.dbFile = dbFile }
|
||||
}
|
||||
|
||||
/** A file whose header is NOT the SQLite magic — what a SQLCipher-encrypted cache looks like. */
|
||||
private fun encryptedFixtureFile(): File = File.createTempFile("guard_encrypted", ".db", context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
writeBytes(ByteArray(48) { 0x5A })
|
||||
}
|
||||
|
||||
/** A file with a genuine plaintext-SQLite 16-byte header (padded past it). */
|
||||
private fun plaintextFixtureFile(): File = File.createTempFile("guard_plaintext", ".db", context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
writeBytes("SQLite format 3".toByteArray(Charsets.US_ASCII) + byteArrayOf(0) + ByteArray(32))
|
||||
}
|
||||
|
||||
private fun pruneWorkerFactory(lazyPruner: Lazy<MailPruner>, cacheGuard: EncryptedCacheGuard) =
|
||||
|
||||
@@ -27,8 +27,10 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.security.SealState
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.BackfillPacer
|
||||
@@ -162,11 +164,15 @@ class FetchGateReceiverInstrumentedTest {
|
||||
return requireNotNull(readBack[0]) { "receiver set no result data" }
|
||||
}
|
||||
|
||||
/** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off) — so only the gate can defer. */
|
||||
/** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off, no seal) — so only the gate can defer. */
|
||||
private fun unlockedGuard(): EncryptedCacheGuard {
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = false, encryptCache = true))
|
||||
return EncryptedCacheGuard(settingsRepository, session)
|
||||
// No seal exists (issue #479: the guard answers from the seal state, not the settings pair),
|
||||
// so the un-armed cache never blocks and only the fetch gate can defer the worker.
|
||||
val keyStore = mockk<DatabaseKeyStore>()
|
||||
coEvery { keyStore.sealState() } returns SealState.NONE
|
||||
return EncryptedCacheGuard(context, settingsRepository, keyStore, session)
|
||||
}
|
||||
|
||||
private fun backfillWorkerFactory(lazyBackfiller: Lazy<MailBackfiller>, cacheGuard: EncryptedCacheGuard) =
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
|
||||
/**
|
||||
* On-device proof of issue #362's proactive auth circuit-breaker on the REAL Android runtime (not the
|
||||
* JVM stubs), across the CI API matrix. A real [AuthThrottleGate] with an always-Yahoo policy and a
|
||||
* manual clock — so it is deterministic and never real-sleeps — must, for a Yahoo/AOL account: block a
|
||||
* login after an auth failure, escalate rapid failures without reaching the ~1-hour lockout window, open
|
||||
* a long fixed circuit past the threshold, isolate accounts, and clear on success; and it must be a
|
||||
* total no-op for a non-gated host.
|
||||
*
|
||||
* Deliberately mock-free (no `mockk`, no framework `Context`): the gate's only inputs are plain lambdas
|
||||
* and value objects, so this exercises the genuine state machine on the device and dodges the
|
||||
* mockk-on-framework-types landmines. The JVM [AuthThrottleGateTest] covers the same contract under
|
||||
* coroutines-test virtual time; this proves it survives the real dispatcher and API levels.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AuthThrottleGateInstrumentedTest {
|
||||
|
||||
private var now = 0L
|
||||
private val yahoo = ProviderAuthPolicy.forHost(MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host)
|
||||
|
||||
private fun gate(policyForHost: (String) -> AuthCadencePolicy = { yahoo }) =
|
||||
AuthThrottleGate(nowMillis = { now }, random = { 0.0 }, policyForHost = policyForHost)
|
||||
|
||||
private fun params(user: String = "user@example.org", host: String = "imap.mail.yahoo.com") =
|
||||
ImapConnectionParams(host, PORT, MailSecurity.SSL_TLS, user, secret = "secret", useXoauth2 = false)
|
||||
|
||||
@Test
|
||||
fun anAuthFailureBlocksTheAccountAndEscalatesWithoutReachingTheLockout() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val first = gate.onAuthFailure(p)
|
||||
assertTrue("a failure blocks the account", gate.isAuthBlocked(p))
|
||||
assertTrue("the first block is positive", first > 0L)
|
||||
|
||||
val second = gate.onAuthFailure(p)
|
||||
assertTrue("a consecutive failure backs off longer", second > first)
|
||||
|
||||
// Never as long as the ~1-hour lockout the backoff exists to avoid.
|
||||
repeat(RAPID_FAILURES) { assertTrue(gate.onAuthFailure(p) < ONE_HOUR_MS) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCircuitLatchesPastTheThresholdAndNeverSelfClears() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
repeat(yahoo.circuitOpenThreshold) { gate.onAuthFailure(p) }
|
||||
assertTrue("reaching the threshold latches the circuit", gate.isAuthLatched(p))
|
||||
assertTrue(gate.isAuthBlocked(p))
|
||||
|
||||
// Issue #362 fail-loud stop: the old self-clearing open-circuit window is gone — time never
|
||||
// unblocks a latch, only a fresh account re-add does.
|
||||
now += yahoo.circuitOpenMillis * LATCH_ELAPSE_FACTOR
|
||||
assertTrue("a latched circuit never self-clears with time", gate.isAuthBlocked(p))
|
||||
|
||||
// A success does not clear a latch either (defensive: no login is attempted while latched).
|
||||
gate.onAuthSuccess(p)
|
||||
assertTrue("a success must not clear a latch", gate.isAuthLatched(p))
|
||||
|
||||
// Only a re-add drops it, letting a fresh credential log in again.
|
||||
gate.onAccountReadded(p)
|
||||
assertFalse("a re-add clears the latch", gate.isAuthLatched(p))
|
||||
assertFalse(gate.isAuthBlocked(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsAreIsolatedAndSuccessClearsTheBackoff() {
|
||||
val gate = gate()
|
||||
val blocked = params(user = "blocked@example.org")
|
||||
val healthy = params(user = "healthy@example.org")
|
||||
|
||||
gate.onAuthFailure(blocked)
|
||||
assertTrue(gate.isAuthBlocked(blocked))
|
||||
assertFalse("one blocked account never stalls another", gate.isAuthBlocked(healthy))
|
||||
|
||||
gate.onAuthSuccess(blocked)
|
||||
assertFalse("a successful login clears the backoff", gate.isAuthBlocked(blocked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWindowClearsOnceItElapses() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val block = gate.onAuthFailure(p)
|
||||
now += block
|
||||
assertFalse("the account may retry once the window elapses", gate.isAuthBlocked(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNonGatedHostIsNeverBlocked() {
|
||||
val gate = gate(policyForHost = ProviderAuthPolicy::forHost)
|
||||
val gmail = params(host = "imap.gmail.com")
|
||||
|
||||
assertEquals(0L, gate.onAuthFailure(gmail))
|
||||
assertFalse(gate.isAuthBlocked(gmail))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PORT = 993
|
||||
const val RAPID_FAILURES = 10
|
||||
const val ONE_HOUR_MS = 60 * 60_000L
|
||||
|
||||
/** How many old open-circuit windows to fast-forward to prove a latch never self-clears (#362). */
|
||||
const val LATCH_ELAPSE_FACTOR = 10
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,9 @@ class AccountDataMigrator @Inject constructor(
|
||||
private val TABLES = listOf("accounts", "credentials", "account_settings", "signatures")
|
||||
|
||||
/**
|
||||
* DDL for the account tables in [AccountDatabase] v3, copied verbatim from the exported Room
|
||||
* schema (`schemas/org.libremail.data.local.AccountDatabase/3.json` — v3 added `accounts.authError`,
|
||||
* issue #362). It MUST stay byte-for-byte identical to what Room generates for those entities, or
|
||||
* DDL for the account tables in [AccountDatabase] v2, copied verbatim from the exported Room
|
||||
* schema (`schemas/org.libremail.data.local.AccountDatabase/2.json` — v2 added `accounts.sortOrder`,
|
||||
* issue #164). It MUST stay byte-for-byte identical to what Room generates for those entities, or
|
||||
* Room silently accepts a subtly wrong schema (its identity check only compares the hash it writes,
|
||||
* not the pre-existing tables).
|
||||
* `AccountDataMigratorTest.migratorDdlMatchesExportedAccountDatabaseSchema` guards it against the
|
||||
@@ -110,7 +110,7 @@ class AccountDataMigrator @Inject constructor(
|
||||
"accounts" to
|
||||
"CREATE TABLE IF NOT EXISTS `accounts` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, " +
|
||||
"`displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, " +
|
||||
"`sortOrder` INTEGER NOT NULL DEFAULT 0, `authError` TEXT, `imap_host` TEXT NOT NULL, " +
|
||||
"`sortOrder` INTEGER NOT NULL DEFAULT 0, `imap_host` TEXT NOT NULL, " +
|
||||
"`imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, " +
|
||||
"`smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"credentials" to
|
||||
|
||||
@@ -40,7 +40,7 @@ import org.libremail.data.local.entity.SignatureEntity
|
||||
AccountSettingsEntity::class,
|
||||
SignatureEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AccountDatabase : RoomDatabase() {
|
||||
|
||||
@@ -31,16 +31,3 @@ val ACCOUNT_MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AccountDatabase v2 -> v3 (issue #362): add the nullable [AccountEntity.authError], a user-facing sync/auth
|
||||
* error surfaced on the account row and as a mailbox banner. The column is nullable with no default, so
|
||||
* every existing account migrates to NULL ("no error, healthy") — matching the entity's `authError: String?
|
||||
* = null` — and the proactive auth circuit later stamps the "remove and re-add" message onto an account
|
||||
* whose Yahoo/AOL login has latched. A plain ADD COLUMN suffices; nothing is backfilled.
|
||||
*/
|
||||
val ACCOUNT_MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `accounts` ADD COLUMN `authError` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -183,6 +184,7 @@ class DatabaseProvisioner internal constructor(
|
||||
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
|
||||
val passphrase = keyStore.resolvePassphrase(appLock)
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
releaseOrphanedAuthSeal()
|
||||
CacheOpenMode.Plaintext
|
||||
}
|
||||
|
||||
@@ -190,6 +192,29 @@ class DatabaseProvisioner internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache is plaintext now, so a passphrase still sealed by the auth-bound key is an orphan:
|
||||
* nothing needs it to open the database, but its presence keeps the app in the issue-#479
|
||||
* transitional state ([org.libremail.data.security.SealState.AUTH] with `encryptCache` off) —
|
||||
* where losing the auth key (device-lock removal / re-enrollment) forces a needless cache wipe.
|
||||
* Reseal it under the non-auth master key (dropping the auth seal and its Keystore key) so the
|
||||
* passphrase stays recoverable and a later `encryptCache` re-enable reuses it seamlessly.
|
||||
* Best-effort: the session holds the just-used passphrase on this path, but if the reseal still
|
||||
* fails the plaintext open must proceed — the lingering seal is handled defensively everywhere
|
||||
* (EncryptedCacheGuard checks the file, the key-invalidation policy treats it as protected).
|
||||
*/
|
||||
private suspend fun releaseOrphanedAuthSeal() {
|
||||
if (!keyStore.hasAuthSealedPassphrase()) return
|
||||
try {
|
||||
keyStore.sealWithMaster()
|
||||
AppLog.i(TAG, "released orphaned auth seal after decrypt-on-disable (resealed under master key)")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
AppLog.w(TAG, "failed to release orphaned auth seal after decrypt-on-disable", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DatabaseProvisioner"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ internal fun AccountEntity.toDomain(): Account = Account(
|
||||
authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP),
|
||||
imap = ServerConfig(imap.host, imap.port, imap.security.toMailSecurity()),
|
||||
smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toMailSecurity()),
|
||||
authError = authError,
|
||||
)
|
||||
|
||||
internal fun Account.toEntity(): AccountEntity = AccountEntity(
|
||||
@@ -49,7 +48,6 @@ internal fun Account.toEntity(): AccountEntity = AccountEntity(
|
||||
authType = authType.name,
|
||||
imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name),
|
||||
smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name),
|
||||
authError = authError,
|
||||
)
|
||||
|
||||
internal fun AccountSettingsEntity.toDomain(): AccountSettings = AccountSettings(
|
||||
|
||||
@@ -86,16 +86,6 @@ interface AccountDao {
|
||||
orderedIds.forEachIndexed { index, id -> setSortOrder(id, index) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a user-facing [AccountEntity.authError] on the account (issue #362), returning the number of
|
||||
* rows actually changed. The `authError IS NOT :message` guard makes it a **conditional** write: it
|
||||
* updates only when the stored value differs (including from NULL), so a caller reconciling the auth
|
||||
* circuit on every sync slice sets the error — and logs it — exactly once, never re-writing the same
|
||||
* message. Pass a non-null message to mark errored.
|
||||
*/
|
||||
@Query("UPDATE accounts SET authError = :message WHERE id = :id AND authError IS NOT :message")
|
||||
suspend fun setAuthError(id: String, message: String): Int
|
||||
|
||||
@Query("DELETE FROM accounts WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
|
||||
@@ -23,14 +23,6 @@ data class AccountEntity(
|
||||
* a migrated one (the folders `specialUse` pattern).
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "0") val sortOrder: Int = 0,
|
||||
/**
|
||||
* A user-facing sync/auth error that has halted this account, or null when healthy (issue #362). Set
|
||||
* to the "remove and re-add" message once the proactive auth circuit **latches** — the account's login
|
||||
* has failed enough consecutive times that a credential fix, not a retry, is required — so the account
|
||||
* list and the mailbox banner can surface it. Nullable with an implicit NULL default, so existing rows
|
||||
* migrate to "no error" and a fresh add (which rewrites the row) clears it.
|
||||
*/
|
||||
val authError: String? = null,
|
||||
)
|
||||
|
||||
/** Embedded host/port/security columns (prefixed per server in [AccountEntity]). */
|
||||
|
||||
@@ -22,7 +22,6 @@ import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import org.libremail.reporting.AppLog
|
||||
@@ -40,7 +39,6 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
private val draftDao: DraftDao,
|
||||
private val credentialStore: CredentialStore,
|
||||
private val imapClient: ImapClient,
|
||||
private val authGate: AuthThrottleGate,
|
||||
private val syncScheduler: SyncScheduler,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val mailNotifier: MailNotifier,
|
||||
@@ -56,13 +54,7 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = runCatching {
|
||||
val params = account.toImapParams(secret = password, useXoauth2 = false)
|
||||
// #362: a re-add is the ONE thing that clears a latched Yahoo/AOL auth circuit. Drop the in-memory
|
||||
// latch BEFORE the connection test so the fresh credential gets a clean login (an un-reset gate would
|
||||
// still refuse it); the account-row rewrite below then clears the persisted authError (a fresh domain
|
||||
// Account carries authError = null, and insertAtEnd's in-place update writes that null).
|
||||
authGate.onAccountReadded(params)
|
||||
val folders = imapClient.listFolders(params)
|
||||
val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false))
|
||||
// Persist the credential BEFORE inserting the account row (#403). Both LibreMailApplication's
|
||||
// push collector and IdleService.reconcileWatchers react to the *accounts* table; committing the
|
||||
// secret first guarantees any watcher that observes the new row can already resolve it, instead
|
||||
@@ -85,11 +77,7 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
authStateJson: String,
|
||||
): Result<List<String>> = runCatching {
|
||||
val account = Account.outlook(email)
|
||||
val params = account.toImapParams(secret = accessToken, useXoauth2 = true)
|
||||
// #362: clear any latched auth circuit on re-add before the connection test, exactly as
|
||||
// addImapAccount does — the account-row rewrite below clears the persisted authError.
|
||||
authGate.onAccountReadded(params)
|
||||
val folders = imapClient.listFolders(params)
|
||||
val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true))
|
||||
// Persist the durable AuthState BEFORE the account row (#403) — same ordering rationale as
|
||||
// addImapAccount: the push watchers observe the accounts table, so the secret must be committed
|
||||
// first for the newly-observed account to resolve. account_settings' FK still needs the row, so
|
||||
|
||||
@@ -1,31 +1,79 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.libremail.data.local.DatabaseEncryption
|
||||
import org.libremail.data.local.DatabaseFiles
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.reporting.AppLog
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Tells UI-less entry points (background sync/push/send) whether opening the Room cache would block
|
||||
* on the user authenticating. When app-lock AND encrypted-cache are on, the SQLCipher passphrase is
|
||||
* sealed by an auth-bound key and only lives in [PassphraseSession] after an unlock — so a worker
|
||||
* that constructs a DAO before then would park a thread indefinitely inside `provideDatabase`.
|
||||
* on the user authenticating — a worker that constructs a DAO while it would must defer
|
||||
* (`Result.retry()`) instead of parking a thread indefinitely inside `provideDatabase`.
|
||||
*
|
||||
* Depends only on DataStore ([SettingsRepository]) and in-memory state ([PassphraseSession]) — never
|
||||
* on the Room database — so it is safe to consult *before* touching any DB-backed dependency.
|
||||
* The answer mirrors [DatabaseKeyStore.resolvePassphrase]'s blocking branches: it is keyed off which
|
||||
* seal actually EXISTS ([DatabaseKeyStore.sealState]), never off the appLock/encryptCache settings
|
||||
* alone, which live in a separate DataStore and can lag the on-disk truth (issue #479):
|
||||
* - [SealState.AUTH]: the passphrase only becomes available after the user authenticates, so the
|
||||
* cache is locked while the session is — INCLUDING the transitional window where `encryptCache`
|
||||
* was already toggled off but the deferred decrypt-to-plaintext (next cold start) hasn't run yet.
|
||||
* Once the file is already plaintext an open no longer needs the passphrase, so a lingering
|
||||
* (orphaned) auth seal alone does not lock it.
|
||||
* - [SealState.MASTER]: the passphrase auto-unwraps without authentication — never locked (e.g.
|
||||
* app-lock was just enabled mid-session and the reseal under the auth key happens only at the
|
||||
* next authentication; stalling sync/push/send until then would be needless).
|
||||
* - [SealState.NONE]: locked only while both settings ask for an auth-armed cache that hasn't been
|
||||
* armed yet (the first-time arm happens at the next authentication).
|
||||
*
|
||||
* Depends only on DataStore ([SettingsRepository], [DatabaseKeyStore]), in-memory state
|
||||
* ([PassphraseSession]) and a raw 16-byte header read of the cache file — never on the Room
|
||||
* database — so it is safe to consult *before* touching any DB-backed dependency.
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedCacheGuard @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val keyStore: DatabaseKeyStore,
|
||||
private val session: PassphraseSession,
|
||||
) {
|
||||
/**
|
||||
* The on-disk cache file whose header decides the transitional [SealState.AUTH] case. A
|
||||
* [VisibleForTesting] seam (mirroring [DatabaseKeyStore.dataStore]) so tests can point the guard
|
||||
* at a fixture file instead of the app's real cache.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal var dbFile: File = context.getDatabasePath(DatabaseFiles.NAME)
|
||||
|
||||
/**
|
||||
* True when the encrypted cache is currently locked, i.e. opening the database would suspend
|
||||
* waiting for the user to authenticate. Background work should defer (e.g. `Result.retry()`).
|
||||
*/
|
||||
suspend fun isCacheLocked(): Boolean {
|
||||
if (session.isUnlocked()) return false
|
||||
val settings = settingsRepository.settings.first()
|
||||
return settings.appLock && settings.encryptCache && !session.isUnlocked()
|
||||
return when (keyStore.sealState()) {
|
||||
SealState.MASTER -> false
|
||||
SealState.NONE -> settings.appLock && settings.encryptCache
|
||||
SealState.AUTH -> {
|
||||
val locked = settings.encryptCache || DatabaseEncryption.isEncrypted(dbFile)
|
||||
if (!settings.encryptCache) {
|
||||
// The transitional window (issue #479): the setting is already off but the
|
||||
// auth seal still exists. Breadcrumb the divergence — booleans only, no PII.
|
||||
AppLog.i(TAG, "auth seal present with encryptCache off (transitional); cacheLocked=$locked")
|
||||
}
|
||||
locked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LibreMailCacheGuard"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ enum class LockAction {
|
||||
CLEAR_AND_REQUIRE_AUTH,
|
||||
|
||||
/**
|
||||
* The device no longer has a secure lock and there is nothing encrypted to protect: silently
|
||||
* disable app-lock (there is no key to authenticate against) and proceed.
|
||||
* The device no longer has a secure lock and there is nothing encrypted to protect — the
|
||||
* encrypted-cache setting is off AND no auth-sealed passphrase exists: silently disable
|
||||
* app-lock (there is no key to authenticate against) and proceed.
|
||||
*/
|
||||
DISABLE_APP_LOCK,
|
||||
|
||||
@@ -31,22 +32,32 @@ enum class LockAction {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision table reconciling the app-lock / encrypted-cache settings with the current device
|
||||
* Pure decision table reconciling the app-lock / encrypted-cache state with the current device
|
||||
* security state and Keystore key validity. Extracted from any Android or crypto dependency so the
|
||||
* (security-critical) branch logic — in particular the "clear + re-sync, never corrupt" handling of
|
||||
* lock removal and biometric re-enrollment — is fully unit-tested.
|
||||
*/
|
||||
object KeyInvalidationPolicy {
|
||||
|
||||
/**
|
||||
* [encryptedCacheProtected] must reflect the ACTUAL protection state, not the `encryptCache`
|
||||
* setting alone: the setting is written immediately but the on-disk conversion is deferred to
|
||||
* the next cold start, so an auth-sealed (still encrypted) cache can outlive the setting being
|
||||
* off. Callers derive it as `encryptCache setting || an auth-sealed passphrase exists`
|
||||
* (issue #479); feeding the raw setting here silently disabled app-lock during that
|
||||
* transitional window and stranded a permanently unreadable cache.
|
||||
*/
|
||||
fun decide(
|
||||
appLockEnabled: Boolean,
|
||||
encryptCacheEnabled: Boolean,
|
||||
encryptedCacheProtected: Boolean,
|
||||
deviceSecure: Boolean,
|
||||
keyInvalidated: Boolean,
|
||||
): LockAction = when {
|
||||
!appLockEnabled -> LockAction.PROCEED
|
||||
!deviceSecure -> if (encryptCacheEnabled) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK
|
||||
keyInvalidated -> if (encryptCacheEnabled) LockAction.CLEAR_AND_REQUIRE_AUTH else LockAction.REQUIRE_AUTH
|
||||
!deviceSecure ->
|
||||
if (encryptedCacheProtected) LockAction.CLEAR_AND_DISABLE else LockAction.DISABLE_APP_LOCK
|
||||
keyInvalidated ->
|
||||
if (encryptedCacheProtected) LockAction.CLEAR_AND_REQUIRE_AUTH else LockAction.REQUIRE_AUTH
|
||||
else -> LockAction.REQUIRE_AUTH
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.accountLogRef
|
||||
|
||||
/** Tag for the account-auth-error reconciliation breadcrumb (issue #362). PII-free. */
|
||||
private const val AUTH_ERROR_TAG = "AccountAuthError"
|
||||
|
||||
/**
|
||||
* Bridges the in-memory proactive auth circuit ([AuthThrottleGate]) to the **durable, user-visible account
|
||||
* error** (issue #362). When [params]'s account has *latched* its circuit — the threshold of consecutive
|
||||
* Yahoo/AOL authentication failures reached, i.e. a wrong app-password that won't fix itself — this persists
|
||||
* the "remove and re-add this account" message onto the account row (via [AccountDao.setAuthError]) so the
|
||||
* account list and the mailbox banner surface it, and returns `true` so the caller stops syncing the account
|
||||
* (fail loud and stop, rather than the old silent self-clearing backoff window).
|
||||
*
|
||||
* The write is conditional (idempotent) at the SQL level, so across the many sync/backfill slices that may
|
||||
* observe the same latch the error is stamped — and the breadcrumb logged — **exactly once**. Returns
|
||||
* `false` and does nothing for a healthy or still-ramping account, and for every non-Yahoo/AOL host (which
|
||||
* never latches). PII-free: only a hashed account ref is ever logged; the account id/email/host/credentials
|
||||
* are not.
|
||||
*
|
||||
* Shared by [MailSyncer] and [MailBackfiller] — the two loops that already hold the [Account] and an
|
||||
* [AccountDao] — rather than injecting a DAO into the mail-layer gate, keeping the persistence write in the
|
||||
* data layer where it belongs.
|
||||
*/
|
||||
internal suspend fun markAccountErroredIfLatched(
|
||||
authGate: AuthThrottleGate,
|
||||
accountDao: AccountDao,
|
||||
context: Context,
|
||||
account: Account,
|
||||
params: ImapConnectionParams,
|
||||
): Boolean {
|
||||
if (!authGate.isAuthLatched(params)) return false
|
||||
val message = context.getString(R.string.account_auth_error_remove_readd)
|
||||
if (accountDao.setAuthError(account.id, message) > 0) {
|
||||
AppLog.w(
|
||||
AUTH_ERROR_TAG,
|
||||
"auth circuit latched ${accountLogRef(account.id)}: account marked errored, sync stopped until re-add",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import org.libremail.data.settings.effectiveRetention
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import org.libremail.reporting.AppLog
|
||||
@@ -63,7 +62,6 @@ class MailBackfiller @Inject constructor(
|
||||
// iCloud-specific connection cap (#363); a no-op for every other provider — see its own KDoc.
|
||||
private val icloudConnectionLimiter: IcloudConnectionLimiter,
|
||||
private val bandwidthTracker: GmailBandwidthTracker,
|
||||
private val authGate: AuthThrottleGate,
|
||||
) {
|
||||
/** One folder's slice outcome: pages fetched, and whether an immediate follow-up slice has work to do. */
|
||||
private data class FolderResult(val batches: Int, val moreWork: Boolean)
|
||||
@@ -80,17 +78,19 @@ class MailBackfiller @Inject constructor(
|
||||
var remaining = maxBatches
|
||||
var moreWork = false
|
||||
accounts@ for (account in accountDao.getAll().map { it.toDomain() }) {
|
||||
// #362 fail-loud stop: an account whose proactive auth circuit latched is persisted as errored
|
||||
// and must not be probed again — skip it entirely (no login), durably across restarts (reads the
|
||||
// persisted authError, not just the in-memory gate), until the user re-adds it.
|
||||
if (account.authError != null) {
|
||||
AppLog.i(TAG, "backfill skip ${accountLogRef(account.id)}: account errored, awaiting re-add")
|
||||
// Graceful degradation + per-account isolation (#360): an account still inside its throttle
|
||||
// backoff window is skipped this slice — we don't page a provider that just rate-limited or
|
||||
// locked us (hammering it makes throttling worse, the on-device perf finding). The window
|
||||
// elapses on its own, so a later scheduled slice resumes this account automatically. A skip
|
||||
// deliberately does NOT set moreWork: a slice whose only outstanding work is a throttled
|
||||
// account reports "done" so the worker's slice-chaining loop stops instead of tight-looping
|
||||
// over the skip. Other accounts are untouched.
|
||||
val backoffRemaining = throttleGate.remainingBackoffMillis(account.id)
|
||||
if (backoffRemaining > 0L) {
|
||||
AppLog.i(TAG, "backfill skip ${accountLogRef(account.id)}: throttled, remaining=${backoffRemaining}ms")
|
||||
continue@accounts
|
||||
}
|
||||
// Skip an account backing off (throttle #360 / auth #362) or with unresolvable credentials —
|
||||
// a null return does NOT set moreWork, so a slice whose only work is a backing-off account
|
||||
// reports "done" instead of tight-looping over the skip. See [paramsForBackfill].
|
||||
val params = paramsForBackfill(account) ?: continue@accounts
|
||||
val params = runCatching { connectionFactory.imapParamsFor(account) }.getOrNull() ?: continue
|
||||
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
|
||||
for (folder in messageDao.syncedFolders(account.id)) {
|
||||
if (remaining <= 0) {
|
||||
@@ -114,37 +114,6 @@ class MailBackfiller @Inject constructor(
|
||||
moreWork
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves [account] to its IMAP connection params for this slice, or **null when the account must be
|
||||
* skipped without paging** — it is inside a reactive throttle-backoff window (#360) or a proactive
|
||||
* auth-backoff window (#362), or its credentials could not be resolved. Both backoffs are graceful
|
||||
* degradation with per-account isolation: we don't page a provider that just rate-limited or locked us
|
||||
* (hammering makes throttling worse — the on-device perf finding), and we don't drive a login that
|
||||
* [org.libremail.mail.ImapClient] would only skip anyway, nudging a Yahoo/AOL account toward its
|
||||
* ~1-hour lockout. Each window elapses on its own so a later scheduled slice resumes; a skip logs a
|
||||
* PII-free breadcrumb and its caller does NOT set moreWork, so a slice whose only outstanding work is a
|
||||
* backing-off account reports "done" rather than tight-looping over the skip.
|
||||
*/
|
||||
private suspend fun paramsForBackfill(account: Account): ImapConnectionParams? {
|
||||
val throttleRemaining = throttleGate.remainingBackoffMillis(account.id)
|
||||
if (throttleRemaining > 0L) {
|
||||
AppLog.i(TAG, "backfill skip ${accountLogRef(account.id)}: throttled, remaining=${throttleRemaining}ms")
|
||||
return null
|
||||
}
|
||||
val params = runCatching { connectionFactory.imapParamsFor(account) }.getOrNull() ?: return null
|
||||
// If the auth circuit latched (via any path) since the durable authError check in runBackfill, stamp
|
||||
// the account error now (once, as a side effect); the block check below then skips it, since a latched
|
||||
// circuit reports a "forever" remaining block — folding the latch skip into the existing backoff skip.
|
||||
val latched = markAccountErroredIfLatched(authGate, accountDao, context, account, params)
|
||||
val authBlock = authGate.remainingAuthBlockMillis(params)
|
||||
if (authBlock > 0L) {
|
||||
val reason = if (latched) "auth circuit latched" else "auth backing off, remaining=${authBlock}ms"
|
||||
AppLog.i(TAG, "backfill skip ${accountLogRef(account.id)}: $reason")
|
||||
return null
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages one folder, translating a failure into the slice's control flow (issue #360). Returns the
|
||||
* [FolderResult] on success — or, for an ordinary transient error, a zero-page result whose
|
||||
|
||||
@@ -12,17 +12,13 @@ import kotlinx.coroutines.withContext
|
||||
import org.libremail.BuildConfig
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.settings.effectiveRetention
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
@@ -46,7 +42,6 @@ class MailSyncer @Inject constructor(
|
||||
private val mailRepository: MailRepository,
|
||||
private val throttleGate: AccountThrottleGate,
|
||||
private val bandwidthTracker: GmailBandwidthTracker,
|
||||
private val authGate: AuthThrottleGate,
|
||||
) : Syncer {
|
||||
// Serializes all syncing: syncAll/syncAccount/syncFolder are invoked concurrently by the periodic
|
||||
// worker, pull-to-refresh, one-shot syncs, folder opens, and one IDLE watcher per account. Without
|
||||
@@ -97,26 +92,9 @@ class MailSyncer @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun syncFolderHeaders(account: Account, folder: String, notify: Boolean): Result<Int> {
|
||||
// #362 fail-loud stop: an account whose proactive auth circuit latched is persisted as errored
|
||||
// (markAccountErroredIfLatched) and must not be probed again — skip it entirely, no login attempt, so
|
||||
// a locked-out Yahoo/AOL account is never nudged further. Durable across restarts: it reads the
|
||||
// persisted account error, not just the in-memory gate.
|
||||
if (account.authError != null) {
|
||||
AppLog.i(TAG, "sync skip ${accountLogRef(account.id)}: account errored, awaiting re-add")
|
||||
return Result.success(0)
|
||||
}
|
||||
// Captured the moment params resolve so onFailure can reconcile the auth circuit without re-resolving
|
||||
// them (which for OAuth would redeem a fresh token); null means no login was attempted.
|
||||
var attemptedParams: ImapConnectionParams? = null
|
||||
return runCatching {
|
||||
val params = connectionFactory.imapParamsFor(account).also { attemptedParams = it }
|
||||
// The circuit may have latched via another path (backfill/IDLE) since the durable check above:
|
||||
// stamp the account error now and skip rather than drive a login the gate would only refuse.
|
||||
if (markAccountErroredIfLatched(authGate, accountDao, context, account, params)) {
|
||||
AppLog.i(TAG, "sync skip ${accountLogRef(account.id)}: auth circuit latched")
|
||||
return@runCatching 0
|
||||
}
|
||||
private suspend fun syncFolderHeaders(account: Account, folder: String, notify: Boolean): Result<Int> =
|
||||
runCatching {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
|
||||
// Never fetch more of the recent window than device-only retention (#13) would keep. Without
|
||||
// this, a count limit BELOW the window would make foreground sync re-download the same rows
|
||||
@@ -131,9 +109,52 @@ class MailSyncer @Inject constructor(
|
||||
val entities = fetched.map { it.toEntity(account.id, folder) }
|
||||
.let { mapped -> if (cutoff == null) mapped else mapped.filter { it.timestampMillis >= cutoff } }
|
||||
|
||||
// Persist the fetched window and notify about new inbox mail, atomically w.r.t. cancellation
|
||||
// (extracted to [persistSyncedWindow] to stay under the complexity gate; behavior-preserving).
|
||||
persistSyncedWindow(account, folder, notify, fetched, entities)
|
||||
// Persist and notify atomically with respect to cancellation: an IDLE renewal that cancels
|
||||
// mid-sync must not drop a notification (the rows would then look "already seen" next time).
|
||||
withContext(NonCancellable) {
|
||||
val existingIds = messageDao.getSyncedIds(account.id, folder).toHashSet()
|
||||
// Don't notify on the very first sync of a folder (would announce everything in it).
|
||||
val newMessages = if (existingIds.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
entities.filter { it.id !in existingIds && !it.isRead }
|
||||
}
|
||||
|
||||
if (fetched.isEmpty()) {
|
||||
// An empty recent window means the server folder itself is empty, so nothing (not
|
||||
// even backfilled history) should remain cached for it. Keyed on the raw fetch, not the
|
||||
// age-filtered set: a folder holding only mail older than the age cutoff is NOT empty on
|
||||
// the server, so its stale local rows are left to the pruner rather than wiped here.
|
||||
messageDao.deleteSyncedByAccountFolder(account.id, folder)
|
||||
} else {
|
||||
val ids = entities.map { it.id }
|
||||
messageDao.insertNew(entities)
|
||||
// Mark every fetched message as synced (upgrades any former search-only row) and refresh
|
||||
// its display fields — without touching cached bodies or optimistic read/star flags. The
|
||||
// per-row refreshes run in a single transaction (issue #310) so a whole recent window
|
||||
// costs one commit instead of one fsync per message (amplified on the encrypted cache).
|
||||
messageDao.markSynced(ids)
|
||||
messageDao.updateHeaderContents(entities)
|
||||
// Reconcile server-side deletions ONLY within the fetched recent-UID window, so older
|
||||
// history paged in by the background backfill (issue #12) survives each foreground sync
|
||||
// instead of being wiped by a whole-folder "not in the recent 50" delete. Bound the
|
||||
// window by the lowest POSITIVE fetched UID: a message whose UID couldn't be resolved
|
||||
// (UIDFolder.getUID returns -1) must not collapse the bound to <= 0 and turn this into a
|
||||
// whole-folder delete that wipes the backfilled history below the window.
|
||||
val minWindowUid = entities.mapNotNull { entity -> entity.uid.takeIf { it > 0L } }.minOrNull()
|
||||
if (minWindowUid != null) {
|
||||
messageDao.deleteSyncedInWindowNotIn(account.id, folder, minWindowUid, ids)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldNotify = notify &&
|
||||
newMessages.isNotEmpty() &&
|
||||
settingsRepository.isNewMailNotificationsEnabled() &&
|
||||
accountSettingsRepository.get(account.id).notificationsEnabled
|
||||
if (shouldNotify) {
|
||||
notifier.notifyNewMail(account, newMessages.sortedByDescending { it.timestampMillis })
|
||||
}
|
||||
}
|
||||
val folderLabel = logSafeFolderLabel(folder)
|
||||
AppLog.d(TAG, "sync ${accountLogRef(account.id)} folder=$folderLabel fetched=${fetched.size}")
|
||||
fetched.size
|
||||
@@ -142,68 +163,7 @@ class MailSyncer @Inject constructor(
|
||||
// the background backfill backs this account off — but the interactive sync itself is never
|
||||
// blocked by the gate, so opening/refreshing mail is never queued behind a backfill backoff.
|
||||
ThrottleClassifier.classify(error)?.let { throttleGate.onThrottle(account.id, it) }
|
||||
// #362: if this very failure crossed the auth-latch threshold, error the account (once) so the
|
||||
// UI shows the "remove and re-add" state instead of silently retrying a doomed credential.
|
||||
attemptedParams?.let { markAccountErroredIfLatched(authGate, accountDao, context, account, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a freshly-fetched recent window and notifies about genuinely new inbox mail, atomically with
|
||||
* respect to cancellation: an IDLE renewal that cancels mid-sync must not drop a notification (the rows
|
||||
* would then look "already seen" next time). Extracted from [syncFolderHeaders] to keep it under the
|
||||
* complexity gate — behavior-preserving.
|
||||
*/
|
||||
private suspend fun persistSyncedWindow(
|
||||
account: Account,
|
||||
folder: String,
|
||||
notify: Boolean,
|
||||
fetched: List<FetchedMessage>,
|
||||
entities: List<MessageEntity>,
|
||||
) = withContext(NonCancellable) {
|
||||
val existingIds = messageDao.getSyncedIds(account.id, folder).toHashSet()
|
||||
// Don't notify on the very first sync of a folder (would announce everything in it).
|
||||
val newMessages = if (existingIds.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
entities.filter { it.id !in existingIds && !it.isRead }
|
||||
}
|
||||
|
||||
if (fetched.isEmpty()) {
|
||||
// An empty recent window means the server folder itself is empty, so nothing (not even
|
||||
// backfilled history) should remain cached for it. Keyed on the raw fetch, not the age-filtered
|
||||
// set: a folder holding only mail older than the age cutoff is NOT empty on the server, so its
|
||||
// stale local rows are left to the pruner rather than wiped here.
|
||||
messageDao.deleteSyncedByAccountFolder(account.id, folder)
|
||||
} else {
|
||||
val ids = entities.map { it.id }
|
||||
messageDao.insertNew(entities)
|
||||
// Mark every fetched message as synced (upgrades any former search-only row) and refresh its
|
||||
// display fields — without touching cached bodies or optimistic read/star flags. The per-row
|
||||
// refreshes run in a single transaction (issue #310) so a whole recent window costs one commit
|
||||
// instead of one fsync per message (amplified on the encrypted cache).
|
||||
messageDao.markSynced(ids)
|
||||
messageDao.updateHeaderContents(entities)
|
||||
// Reconcile server-side deletions ONLY within the fetched recent-UID window, so older history
|
||||
// paged in by the background backfill (issue #12) survives each foreground sync instead of being
|
||||
// wiped by a whole-folder "not in the recent 50" delete. Bound the window by the lowest POSITIVE
|
||||
// fetched UID: a message whose UID couldn't be resolved (UIDFolder.getUID returns -1) must not
|
||||
// collapse the bound to <= 0 and turn this into a whole-folder delete that wipes the backfilled
|
||||
// history below the window.
|
||||
val minWindowUid = entities.mapNotNull { entity -> entity.uid.takeIf { it > 0L } }.minOrNull()
|
||||
if (minWindowUid != null) {
|
||||
messageDao.deleteSyncedInWindowNotIn(account.id, folder, minWindowUid, ids)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldNotify = notify &&
|
||||
newMessages.isNotEmpty() &&
|
||||
settingsRepository.isNewMailNotificationsEnabled() &&
|
||||
accountSettingsRepository.get(account.id).notificationsEnabled
|
||||
if (shouldNotify) {
|
||||
notifier.notifyNewMail(account, newMessages.sortedByDescending { it.timestampMillis })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggressively pre-caches each not-yet-fetched message's full content (body + attachments) per the
|
||||
|
||||
@@ -11,7 +11,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.libremail.data.local.ACCOUNT_MIGRATION_1_2
|
||||
import org.libremail.data.local.ACCOUNT_MIGRATION_2_3
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.CacheEncryptionUnavailableException
|
||||
import org.libremail.data.local.DatabaseFiles.ACCOUNTS_NAME
|
||||
@@ -54,7 +53,7 @@ object AccountDatabaseModule {
|
||||
@ApplicationContext context: Context,
|
||||
provisioner: DatabaseProvisioner,
|
||||
): AccountDatabase = Room.databaseBuilder(context, AccountDatabase::class.java, ACCOUNTS_NAME)
|
||||
.addMigrations(ACCOUNT_MIGRATION_1_2, ACCOUNT_MIGRATION_2_3)
|
||||
.addMigrations(ACCOUNT_MIGRATION_1_2)
|
||||
.openHelperFactory(
|
||||
DeferredOpenHelperFactory { configuration ->
|
||||
try {
|
||||
|
||||
@@ -17,13 +17,6 @@ data class Account(
|
||||
val authType: AuthType,
|
||||
val imap: ServerConfig,
|
||||
val smtp: ServerConfig,
|
||||
/**
|
||||
* A user-facing error that has halted this account's sync, or null when healthy (issue #362). Carries
|
||||
* the "remove and re-add this account" message once its Yahoo/AOL auth circuit latches after repeated
|
||||
* authentication failures; the UI renders it on the account row and as a mailbox banner. Cleared by a
|
||||
* fresh re-add. Defaulted so it is not a required field at construction (setup, tests).
|
||||
*/
|
||||
val authError: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val OUTLOOK_IMAP_PORT = 993
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* The pure, proactive **auth-backoff schedule** for issue #362: given a provider's [AuthCadencePolicy]
|
||||
* and how many times in a row an account has failed to authenticate ([consecutiveFailures], 1-based),
|
||||
* returns how long to block further login attempts. Side-effect- and clock-free (the caller supplies the
|
||||
* jitter draw) so the whole schedule is deterministically unit-testable; [AuthThrottleGate] owns the
|
||||
* per-account state, clock, and logging.
|
||||
*
|
||||
* Two regimes, both aimed at never tripping Yahoo/AOL's ~1-hour auth lockout:
|
||||
* - **Ramp** (`failures < circuitOpenThreshold`): exponential in the failure count
|
||||
* (`base * 2^(failures-1)`), clamped to [AuthCadencePolicy.maxBackoffMillis], with **equal jitter** —
|
||||
* half the capped target as a floor, the other half spread by [random] — so the result lies in
|
||||
* `[capped/2, capped]` and a fleet throttled at once doesn't retry in lockstep. Mirrors the equal-jitter
|
||||
* math of issue #360's [org.libremail.data.sync.ThrottleBackoff].
|
||||
* - **Open circuit** (`failures >= circuitOpenThreshold`): a single long, *fixed* block
|
||||
* ([AuthCadencePolicy.circuitOpenMillis]) — "back off long and stop". Deliberately un-jittered: once we
|
||||
* give up probing, the window is a firm floor, not something jitter can shorten.
|
||||
*
|
||||
* A [disabled][AuthCadencePolicy.enabled] policy always returns `0` (never blocks), so non-Yahoo/AOL
|
||||
* hosts are unaffected.
|
||||
*/
|
||||
object AuthBackoff {
|
||||
|
||||
/** Caps the exponential shift so `base shl (failures-1)` can never overflow before the cap applies. */
|
||||
private const val MAX_SHIFT = 16
|
||||
|
||||
/**
|
||||
* Block duration in milliseconds for the given 1-based [consecutiveFailures] under [policy], with the
|
||||
* equal-jitter draw [random] (expected in `[0.0, 1.0)`). See the class doc for the ramp vs.
|
||||
* open-circuit regimes.
|
||||
*/
|
||||
fun blockMillis(policy: AuthCadencePolicy, consecutiveFailures: Int, random: Double): Long {
|
||||
require(consecutiveFailures >= 1) { "consecutiveFailures must be >= 1" }
|
||||
if (!policy.enabled) return 0L
|
||||
if (consecutiveFailures >= policy.circuitOpenThreshold) return policy.circuitOpenMillis
|
||||
val shift = min(consecutiveFailures - 1, MAX_SHIFT)
|
||||
val exponential = policy.baseBackoffMillis shl shift
|
||||
// shl can overflow to <= 0 for a pathological count; treat that as "past the cap".
|
||||
val capped = if (exponential in 1..policy.maxBackoffMillis) exponential else policy.maxBackoffMillis
|
||||
val half = capped / 2
|
||||
val jitter = (random.coerceIn(0.0, 1.0) * half).toLong()
|
||||
return half + jitter
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import jakarta.mail.MessagingException
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.accountLogRef
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Thrown by [ImapClient] instead of attempting a `LOGIN` while an account is inside its proactive
|
||||
* auth-backoff window (issue #362): the login is *skipped*, not merely retried, so a Yahoo/AOL account
|
||||
* never accumulates the rapid failed logins that trip the ~1-hour lockout. Extends [MessagingException]
|
||||
* so existing IMAP error handling catches it, and carries **no PII** — only the remaining wait. Its
|
||||
* message deliberately avoids any throttle/lock wording so [org.libremail.data.sync.ThrottleClassifier]
|
||||
* does not misread it as a *reactive* throttle signal, and [ImapConnectionCache] does not treat it as a
|
||||
* connection drop (its cause is null), so a reused connection is never needlessly rebuilt on it.
|
||||
*/
|
||||
class AuthBackoffException(remainingMillis: Long) :
|
||||
MessagingException("IMAP login paused: backing off ${remainingMillis}ms to protect the account")
|
||||
|
||||
/**
|
||||
* Per-account **proactive auth circuit-breaker** for issue #362 — the enforcing state machine that turns
|
||||
* repeated authentication failures into an [AuthBackoff]-scheduled block, so LibreMail never hammers
|
||||
* Yahoo/AOL's login endpoint into its automated **~1-hour lockout**.
|
||||
*
|
||||
* This is the proactive counterpart to issue #360's reactive [org.libremail.data.sync.AccountThrottleGate]:
|
||||
* that gate reacts to a throttle/lock response the server *already sent*; this one prevents us from ever
|
||||
* eliciting one. [ImapClient] consults [remainingAuthBlockMillis] before every real `LOGIN` (connect-per-op,
|
||||
* the reuse cache's connect/reconnect, and the long-lived IDLE connection) and throws [AuthBackoffException]
|
||||
* instead of connecting while blocked; it records the outcome via [onAuthFailure] / [onAuthSuccess]. The
|
||||
* full-history backfill ([org.libremail.data.sync.MailBackfiller]) additionally *skips* an auth-blocked
|
||||
* account, exactly as it skips a reactively-throttled one (#360) — so the two gates compose and the
|
||||
* [org.libremail.data.sync.BackfillPacer] (#356) never burns cooldowns spinning on a blocked login.
|
||||
*
|
||||
* **Only Yahoo/AOL are gated.** State is created solely when [ProviderAuthPolicy.forHost] returns an
|
||||
* enabled policy, so every other provider (Gmail/iCloud/Outlook, issues #361/#363/#364, and manual
|
||||
* servers) is a no-op here and unchanged.
|
||||
*
|
||||
* **Per-account isolation & PII-free.** State is keyed by the account's connection identity
|
||||
* (`host|port|username`), so one blocked account never stalls another, and every log line uses
|
||||
* [accountLogRef] over that key — a salted-looking hash, never the address or host.
|
||||
*
|
||||
* **Fail-loud latch (issue #362).** Past [AuthCadencePolicy.circuitOpenThreshold] consecutive failures the
|
||||
* circuit **latches**: a wrong app-password does not fix itself, so instead of a self-clearing window we
|
||||
* stop retrying *entirely* and hold the account blocked forever. The latch is surfaced to the user as a
|
||||
* persisted account error ("remove and re-add") by
|
||||
* [org.libremail.data.sync.markAccountErroredIfLatched], and is cleared only by a fresh account re-add
|
||||
* ([onAccountReadded]) — never by time or a stray success.
|
||||
*
|
||||
* The in-memory latch itself lives only in-process (`@Singleton`); the durable stop is the persisted
|
||||
* account error, which the sync/backfill loops honour across restarts, so a process restart does not
|
||||
* quietly resume probing a latched account. Below the threshold, the in-memory ramp is transient — a
|
||||
* restart there simply re-probes (spaced from the previous run), and any real re-failure re-arms it.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthThrottleGate internal constructor(
|
||||
private val nowMillis: () -> Long,
|
||||
private val random: () -> Double,
|
||||
private val policyForHost: (String) -> AuthCadencePolicy,
|
||||
) {
|
||||
/** Production wiring: the real wall clock, a per-thread RNG for jitter, and the host-keyed policy. */
|
||||
@Inject
|
||||
constructor() : this(
|
||||
nowMillis = System::currentTimeMillis,
|
||||
random = { ThreadLocalRandom.current().nextDouble() },
|
||||
policyForHost = ProviderAuthPolicy::forHost,
|
||||
)
|
||||
|
||||
/**
|
||||
* One account's auth state: consecutive failures, until when logins are blocked, the last computed
|
||||
* wait, and whether the circuit has **latched** — a permanent fail-loud stop past the threshold that
|
||||
* clears only on a fresh account re-add (issue #362), never by time or a success.
|
||||
*/
|
||||
private data class State(
|
||||
val failures: Int,
|
||||
val blockedUntilMillis: Long,
|
||||
val lastBlockMillis: Long,
|
||||
val latched: Boolean,
|
||||
)
|
||||
|
||||
private val states = ConcurrentHashMap<String, State>()
|
||||
|
||||
/**
|
||||
* Records a failed authentication for [params]'s account and returns the resulting block in ms (0 when
|
||||
* the host has no auth-lockout risk, so the call is a no-op). Below the threshold it escalates the
|
||||
* consecutive-failure count so repeats back off exponentially and stamps the account blocked until
|
||||
* `now + block`. At the threshold the circuit **latches**: a permanent block (fail-loud stop) that no
|
||||
* further failure escalates and no success or elapsed time clears — only [onAccountReadded] does
|
||||
* (issue #362). Atomic per account. Logs a PII-free breadcrumb (the latch transition once, or the
|
||||
* failure count + block while ramping).
|
||||
*/
|
||||
fun onAuthFailure(params: ImapConnectionParams): Long {
|
||||
val policy = policyForHost(params.host)
|
||||
if (!policy.enabled) return 0L
|
||||
val now = nowMillis()
|
||||
var alreadyLatched = false
|
||||
val updated = states.compute(key(params)) { _, previous ->
|
||||
// A latched circuit is a permanent stop: further failures neither escalate nor re-arm it (and
|
||||
// the failure count stays frozen), so we never spam the log or drift the state once we give up.
|
||||
if (previous?.latched == true) {
|
||||
alreadyLatched = true
|
||||
return@compute previous
|
||||
}
|
||||
val failures = (previous?.failures ?: 0) + 1
|
||||
val block = AuthBackoff.blockMillis(policy, failures, random())
|
||||
val latched = failures >= policy.circuitOpenThreshold
|
||||
State(
|
||||
failures = failures,
|
||||
// Latched: block "forever" (Long.MAX_VALUE never elapses) so every subsequent login is
|
||||
// skipped until a re-add — the fail-loud stop that replaced the old self-clearing window.
|
||||
blockedUntilMillis = if (latched) Long.MAX_VALUE else now + block,
|
||||
lastBlockMillis = block,
|
||||
latched = latched,
|
||||
)
|
||||
}!!
|
||||
when {
|
||||
alreadyLatched -> Unit // logged once when it first latched; stay silent thereafter
|
||||
updated.latched -> AppLog.w(
|
||||
TAG,
|
||||
"auth circuit latched ${logRef(params)} after ${updated.failures} failure(s): " +
|
||||
"retries stopped, account will be errored",
|
||||
)
|
||||
else -> AppLog.w(
|
||||
TAG,
|
||||
"auth backoff ${logRef(params)} failures=${updated.failures} block=${updated.lastBlockMillis}ms",
|
||||
)
|
||||
}
|
||||
return updated.lastBlockMillis
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a *ramping* (not yet latched) auth-backoff for [params]'s account after a successful login, so
|
||||
* a recovered account resumes at full speed with the failure count reset. A **latched** circuit is
|
||||
* deliberately left intact — it is cleared only by a fresh account re-add ([onAccountReadded]), never by
|
||||
* a success (issue #362); while latched no login is even attempted, so this is a defensive guard. Silent
|
||||
* no-op when the account was not blocked (or the host is not gated), so [ImapClient] can call it on every
|
||||
* successful connect.
|
||||
*/
|
||||
fun onAuthSuccess(params: ImapConnectionParams) {
|
||||
var cleared: State? = null
|
||||
states.compute(key(params)) { _, current ->
|
||||
when {
|
||||
current == null -> null
|
||||
current.latched -> current // a latched circuit clears only on re-add, never on success
|
||||
else -> {
|
||||
cleared = current
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
cleared?.let { AppLog.i(TAG, "auth recovered ${logRef(params)} after ${it.failures} failure(s)") }
|
||||
}
|
||||
|
||||
/**
|
||||
* True once [params]'s account has permanently **latched** its auth circuit (issue #362) — the
|
||||
* threshold of consecutive Yahoo/AOL auth failures reached — so it must be surfaced to the user as
|
||||
* errored and no login retried until a fresh account re-add.
|
||||
*/
|
||||
fun isAuthLatched(params: ImapConnectionParams): Boolean = states[key(params)]?.latched == true
|
||||
|
||||
/**
|
||||
* Drops ALL auth state for [params]'s account — including a permanently latched circuit — because the
|
||||
* user re-added the account with fresh credentials (issue #362). This is the single path that escapes a
|
||||
* latch: the next login is then attempted clean. Called from
|
||||
* [org.libremail.data.repository.AccountRepositoryImpl] on add (before the connection test), paired with
|
||||
* clearing the persisted account error, so a re-add fully resumes sync.
|
||||
*/
|
||||
fun onAccountReadded(params: ImapConnectionParams) {
|
||||
if (states.remove(key(params)) != null) {
|
||||
AppLog.i(TAG, "auth state reset ${logRef(params)}: account re-added, retries resume")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds until [params]'s account may attempt a login again, or 0 when it is not blocked (or the
|
||||
* window already elapsed). A passed window keeps its failure count until the next [onAuthSuccess], so a
|
||||
* re-failure before recovery escalates rather than restarting from the base delay.
|
||||
*/
|
||||
fun remainingAuthBlockMillis(params: ImapConnectionParams): Long {
|
||||
val state = states[key(params)] ?: return 0L
|
||||
return (state.blockedUntilMillis - nowMillis()).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
/** True while [params]'s account is inside its auth-backoff window and a login must be skipped. */
|
||||
fun isAuthBlocked(params: ImapConnectionParams): Boolean = remainingAuthBlockMillis(params) > 0L
|
||||
|
||||
/** PII-free, stable reference for [params]'s account — a hash of the connection identity, never it. */
|
||||
fun logRef(params: ImapConnectionParams): String = accountLogRef(key(params))
|
||||
|
||||
/** Connection identity keying the state: everything that pins a distinct authenticated login. */
|
||||
private fun key(params: ImapConnectionParams): String = "${params.host}|${params.port}|${params.username}"
|
||||
|
||||
private companion object {
|
||||
const val TAG = "AuthThrottleGate"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import jakarta.mail.AuthenticationFailedException
|
||||
import jakarta.mail.FetchProfile
|
||||
import jakarta.mail.Flags
|
||||
import jakarta.mail.Folder
|
||||
@@ -115,12 +114,6 @@ class ImapClient internal constructor(
|
||||
* no-UIDPLUS fallback path on its own.
|
||||
*/
|
||||
private val supportsUidPlus: (IMAPFolder) -> Boolean = ::probeUidPlusCapability,
|
||||
/**
|
||||
* Proactive auth circuit-breaker (issue #362): consulted before every real `LOGIN` so a Yahoo/AOL
|
||||
* account never accumulates the rapid failed logins that trip its ~1-hour lockout. A no-op for every
|
||||
* other host. Defaulted here so the test/harness seam constructs one without extra wiring.
|
||||
*/
|
||||
private val authGate: AuthThrottleGate = AuthThrottleGate(),
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -132,10 +125,7 @@ class ImapClient internal constructor(
|
||||
* `false` (a build-config change, no code edit) restores connect-per-operation if a server
|
||||
* misbehaves with a kept-alive socket. The internal constructor is the test/harness seam.
|
||||
*/
|
||||
@Inject constructor(authGate: AuthThrottleGate) : this(
|
||||
reuseConnections = BuildConfig.IMAP_CONNECTION_REUSE,
|
||||
authGate = authGate,
|
||||
)
|
||||
@Inject constructor() : this(reuseConnections = BuildConfig.IMAP_CONNECTION_REUSE)
|
||||
|
||||
/**
|
||||
* Per-account keep-alive cache; allocated only when reuse is enabled, so a reuse-disabled build
|
||||
@@ -550,13 +540,9 @@ class ImapClient internal constructor(
|
||||
* connection to unblock idle()) or a connection error is thrown, leaving reconnection to the caller.
|
||||
*/
|
||||
suspend fun idle(params: ImapConnectionParams, onActivity: suspend () -> Unit) = withContext(Dispatchers.IO) {
|
||||
// Proactive auth circuit-breaker (issue #362): the IDLE reconnect loop (IdleService) is the fastest
|
||||
// login repeater — an unguarded auth failure there would storm Yahoo/AOL into their ~1-hour lockout
|
||||
// — so skip the LOGIN while backing off, and feed the gate exactly as the connect-per-op path does.
|
||||
guardAuthBackoff(params, op = "IDLE login")
|
||||
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
|
||||
val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol)
|
||||
connectRecordingAuth(store, params)
|
||||
store.connect(params.host, params.port, params.username, params.secret)
|
||||
// Close the just-connected store if opening the folder fails, so a failed connect in the
|
||||
// IDLE reconnect loop can't leak connections until the server's per-account limit is hit.
|
||||
val inbox = try {
|
||||
@@ -729,51 +715,14 @@ class ImapClient internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and authenticates a fresh [Store] (`CONNECT + TLS + LOGIN`); the caller owns closing it.
|
||||
*
|
||||
* Gated by the proactive auth circuit-breaker (issue #362): while the account is inside its
|
||||
* auth-backoff window the `LOGIN` is *skipped* ([guardAuthBackoff] throws [AuthBackoffException])
|
||||
* rather than attempted, so a Yahoo/AOL account never accumulates the rapid failed logins that trip
|
||||
* its ~1-hour lockout. An authentication failure feeds [AuthThrottleGate.onAuthFailure]; a success
|
||||
* clears it. A transient (non-auth) connect error is rethrown untouched, never arming the backoff.
|
||||
*/
|
||||
/** Builds and authenticates a fresh [Store] (`CONNECT + TLS + LOGIN`); the caller owns closing it. */
|
||||
private fun openConnectedStore(params: ImapConnectionParams): Store {
|
||||
guardAuthBackoff(params, op = "login")
|
||||
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
|
||||
val store = Session.getInstance(buildProps(protocol, params, reuse = reuseConnections)).getStore(protocol)
|
||||
connectRecordingAuth(store, params)
|
||||
store.connect(params.host, params.port, params.username, params.secret)
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips a login while the account is auth-backing-off (issue #362) by throwing [AuthBackoffException],
|
||||
* so no `LOGIN` reaches the provider. Logged at INFO — an expected, protective skip, not an error.
|
||||
* A no-op for non-gated (non-Yahoo/AOL) hosts, whose [AuthThrottleGate.remainingAuthBlockMillis] is 0.
|
||||
*/
|
||||
private fun guardAuthBackoff(params: ImapConnectionParams, op: String) {
|
||||
val remaining = authGate.remainingAuthBlockMillis(params)
|
||||
if (remaining > 0L) {
|
||||
AppLog.i(TAG, "$op skipped ${authGate.logRef(params)}: auth backing off ${remaining}ms")
|
||||
throw AuthBackoffException(remaining)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the actual `store.connect` (`CONNECT + TLS + LOGIN`) and feeds the issue-#362 auth
|
||||
* circuit-breaker: a rejected LOGIN ([isAuthFailure]) arms the backoff via [AuthThrottleGate], a
|
||||
* success clears it, and a transient (non-auth) error is rethrown untouched — never arming it.
|
||||
*/
|
||||
private fun connectRecordingAuth(store: Store, params: ImapConnectionParams) {
|
||||
try {
|
||||
store.connect(params.host, params.port, params.username, params.secret)
|
||||
} catch (e: Throwable) {
|
||||
if (isAuthFailure(e)) authGate.onAuthFailure(params)
|
||||
throw e
|
||||
}
|
||||
authGate.onAuthSuccess(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down every kept-alive reused connection (`LOGOUT` + teardown); a no-op when reuse is
|
||||
* disabled. `IdleService` drives this on the low-battery push-teardown path (#88/#89/#90), mirroring
|
||||
@@ -850,23 +799,6 @@ private fun probeUidPlusCapability(folder: IMAPFolder): Boolean = runCatching {
|
||||
folder.doCommand { protocol -> protocol.hasCapability(CAP_UIDPLUS) } as? Boolean
|
||||
}.getOrNull() ?: false
|
||||
|
||||
/**
|
||||
* True when [error] (or anything in its cause chain) is an [AuthenticationFailedException] — a rejected
|
||||
* `LOGIN`, the only signal the issue-#362 auth circuit-breaker counts. Deliberately narrow: a transient
|
||||
* network/socket error is NOT an auth failure and must never arm the backoff. Guards against a cyclic
|
||||
* cause chain with an identity-based visited check, mirroring [ImapAuthError]'s walk.
|
||||
*/
|
||||
private fun isAuthFailure(error: Throwable): Boolean {
|
||||
val seen = mutableListOf<Throwable>()
|
||||
var current: Throwable? = error
|
||||
while (current != null && seen.none { it === current }) {
|
||||
if (current is AuthenticationFailedException) return true
|
||||
seen.add(current)
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [part] is a user-facing downloadable attachment: its `Content-Disposition` is
|
||||
* `attachment`, OR it has a filename but no `Content-ID` header. A part with a filename AND a
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import org.libremail.domain.model.MailProvider
|
||||
|
||||
/**
|
||||
* Per-provider IMAP limits and the **proactive auth cadence** for issue #362, keyed by IMAP host.
|
||||
*
|
||||
* This is the *config* half of the Yahoo/AOL work; the enforcing state machine is [AuthThrottleGate]
|
||||
* and the pure schedule is [AuthBackoff]. It deliberately parallels issue #360's *reactive* family
|
||||
* ([org.libremail.data.sync.ThrottleBackoff] / [org.libremail.data.sync.AccountThrottleGate]) — that
|
||||
* family reacts to a throttle/lock response the server *already sent*; this one is proactive, spacing
|
||||
* out login attempts so we never **reach** the response in the first place.
|
||||
*
|
||||
* **Why Yahoo/AOL are special.** Yahoo (and AOL, which shares Yahoo's mail platform) trip an automated
|
||||
* **~1-hour service lockout** after too many rapid or failed authentication attempts, and cap a
|
||||
* mailbox at **5 simultaneous IMAP connections** with the folder index truncated to **10,000
|
||||
* messages** (issue #362's documented limits). The lockout is the dangerous one: a wrong app-password
|
||||
* plus a naive retry loop (e.g. the IDLE reconnect loop, which starts at a 5-second backoff) can fire
|
||||
* several failed `LOGIN`s within the first minute and get a *real user* locked out for an hour. So for
|
||||
* these hosts the app must back off login attempts long and hard.
|
||||
*
|
||||
* **Every other provider is disabled here** ([AuthCadencePolicy.DISABLED]): Gmail, iCloud, Outlook,
|
||||
* and manually-configured servers have no comparable 1-hour auth lockout, so the proactive
|
||||
* circuit-breaker is a Yahoo/AOL-scoped no-op for them and their behaviour is unchanged (their own
|
||||
* limits are issues #361/#363/#364). Keeping the policy host-keyed — rather than refactoring shared
|
||||
* code — is what makes this change additive and safe to land alongside those siblings.
|
||||
*/
|
||||
data class AuthCadencePolicy(
|
||||
/** When false the whole proactive auth circuit-breaker is inert for this host (records nothing). */
|
||||
val enabled: Boolean,
|
||||
/** First-failure backoff before a retry is permitted; doubles per consecutive failure. */
|
||||
val baseBackoffMillis: Long,
|
||||
/** Ceiling on the exponential ramp, so a single wait never grows without bound. */
|
||||
val maxBackoffMillis: Long,
|
||||
/** Consecutive failures after which the circuit *opens* — retries stop for [circuitOpenMillis]. */
|
||||
val circuitOpenThreshold: Int,
|
||||
/** The long, fixed block applied once the circuit is open: "back off long and stop" (issue #362). */
|
||||
val circuitOpenMillis: Long,
|
||||
/** Documented simultaneous-connection ceiling for this provider (see [ProviderAuthPolicy]). */
|
||||
val maxConcurrentConnections: Int,
|
||||
/** Documented server-side folder-index truncation (messages) for this provider. */
|
||||
val folderIndexCap: Int,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The inert policy for every host without a Yahoo-style auth lockout. Every threshold is set so
|
||||
* the gate can never block ([circuitOpenThreshold] unreachable, caps effectively unbounded), so a
|
||||
* non-Yahoo/AOL account is never gated and behaves exactly as before issue #362.
|
||||
*/
|
||||
val DISABLED = AuthCadencePolicy(
|
||||
enabled = false,
|
||||
baseBackoffMillis = 0L,
|
||||
maxBackoffMillis = 0L,
|
||||
circuitOpenThreshold = Int.MAX_VALUE,
|
||||
circuitOpenMillis = 0L,
|
||||
maxConcurrentConnections = Int.MAX_VALUE,
|
||||
folderIndexCap = Int.MAX_VALUE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the [AuthCadencePolicy] for an IMAP host. Yahoo and AOL (one platform) get the conservative
|
||||
* lockout-avoiding policy; everything else gets [AuthCadencePolicy.DISABLED].
|
||||
*/
|
||||
object ProviderAuthPolicy {
|
||||
|
||||
private const val MINUTE_MS = 60_000L
|
||||
|
||||
/**
|
||||
* First-failure auth backoff (1 min → a 30 s floor after equal jitter, see [AuthBackoff]). The whole
|
||||
* point is that the **second** login attempt lands ≥30 s after the first: Yahoo's lockout keys on
|
||||
* *rapid* failures (attempts seconds apart, as an unguarded reconnect loop produces), and a ≥30 s
|
||||
* spacing is decisively not rapid. This floor is well under the ~1-hour lockout window it protects.
|
||||
*/
|
||||
const val YAHOO_AUTH_BACKOFF_BASE_MS = MINUTE_MS
|
||||
|
||||
/**
|
||||
* Ceiling on the exponential ramp (15 min). Comfortably under the ~1-hour lockout, so an account that
|
||||
* recovers (a transient auth blip clears, or the user fixes the credential) resumes far sooner than a
|
||||
* self-inflicted hour of silence, while still spacing attempts to at most a few per hour.
|
||||
*/
|
||||
const val YAHOO_AUTH_BACKOFF_MAX_MS = 15 * MINUTE_MS
|
||||
|
||||
/**
|
||||
* Consecutive failed logins after which the circuit opens (4). A wrong app-password does not fix
|
||||
* itself, so once we have failed this many times in a row we stop *ramping* and switch to the long
|
||||
* fixed [YAHOO_AUTH_CIRCUIT_OPEN_MS] block — "back off long and stop" — rather than keep probing and
|
||||
* risk accumulating enough failures to trip the lockout. Reached in ~3.5 min of spaced attempts.
|
||||
*/
|
||||
const val YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD = 4
|
||||
|
||||
/**
|
||||
* The block applied once the circuit is open (30 min). Longer than the 15-min ramp cap (we have given
|
||||
* up probing) yet still **under** the ~1-hour lockout, so recovery beats the lockout — and long
|
||||
* enough that Yahoo's short rolling failure window fully decays between our probes, holding us to at
|
||||
* most ~2 failed logins/hour once open: no rolling-window lockout heuristic reads that as rapid.
|
||||
*/
|
||||
const val YAHOO_AUTH_CIRCUIT_OPEN_MS = 30 * MINUTE_MS
|
||||
|
||||
/**
|
||||
* Yahoo/AOL's documented simultaneous-connection ceiling (5). LibreMail stays well under this by
|
||||
* design: connection reuse (issues #125/#357, ON by default) collapses a whole account to ~1 warm
|
||||
* IMAP socket plus at most one long-lived IDLE connection — 2 per account, not the `1 + K +
|
||||
* attachments` sockets the connect-per-operation path once opened per backfill page. Exposed as
|
||||
* config so the invariant is checkable (see the policy tests) rather than only implicit.
|
||||
*/
|
||||
const val YAHOO_MAX_CONCURRENT_CONNECTIONS = 5
|
||||
|
||||
/**
|
||||
* Yahoo/AOL's documented folder-index truncation (10,000 messages). The full-history backfill
|
||||
* (issue #12) already respects this for free: paging older-than-UID simply returns empty once the
|
||||
* server exposes nothing beyond the truncation point, which the backfiller treats as "folder fully
|
||||
* backfilled". Exposed as config for visibility and so a future page-cap can reference it.
|
||||
*/
|
||||
const val YAHOO_FOLDER_INDEX_CAP = 10_000
|
||||
|
||||
/** Shared by Yahoo and AOL — one mail platform, one set of limits. */
|
||||
private val YAHOO_AOL = AuthCadencePolicy(
|
||||
enabled = true,
|
||||
baseBackoffMillis = YAHOO_AUTH_BACKOFF_BASE_MS,
|
||||
maxBackoffMillis = YAHOO_AUTH_BACKOFF_MAX_MS,
|
||||
circuitOpenThreshold = YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD,
|
||||
circuitOpenMillis = YAHOO_AUTH_CIRCUIT_OPEN_MS,
|
||||
maxConcurrentConnections = YAHOO_MAX_CONCURRENT_CONNECTIONS,
|
||||
folderIndexCap = YAHOO_FOLDER_INDEX_CAP,
|
||||
)
|
||||
|
||||
/**
|
||||
* The policy for [host] (the account's IMAP host). Yahoo/AOL — matched via the single source of truth
|
||||
* [MailProvider.forImapHost], including host aliases — get [YAHOO_AOL]; everything else, including a
|
||||
* null/blank or unknown host, gets [AuthCadencePolicy.DISABLED].
|
||||
*/
|
||||
fun forHost(host: String): AuthCadencePolicy = when (MailProvider.forImapHost(host)) {
|
||||
MailProvider.YAHOO, MailProvider.AOL -> YAHOO_AOL
|
||||
else -> AuthCadencePolicy.DISABLED
|
||||
}
|
||||
}
|
||||
@@ -142,9 +142,19 @@ class AppLockViewModel @Inject constructor(
|
||||
// renders before the (async) decision lands.
|
||||
if (_uiState.value == AppLockUiState.Unlocked) _uiState.value = AppLockUiState.Checking
|
||||
val action = withContext(defaultDispatcher) {
|
||||
// Key the decision off the ACTUAL protection state, not the encryptCache setting
|
||||
// alone: the setting flips immediately, but the on-disk decrypt runs at the next
|
||||
// cold start, so an auth-sealed (still encrypted) cache can outlive
|
||||
// `encryptCache == false`. Losing the auth key in that window must CLEAR (wipe +
|
||||
// re-sync), never silently disable the lock and strand the seal — the same
|
||||
// gate-on-the-seal fix SettingsViewModel.setAppLock already carries (issue #479).
|
||||
val authSealed = databaseKeyStore.hasAuthSealedPassphrase()
|
||||
if (authSealed && !settings.encryptCache) {
|
||||
AppLog.i(TAG, "encryptCache off but passphrase still auth-sealed; treating cache as protected")
|
||||
}
|
||||
KeyInvalidationPolicy.decide(
|
||||
appLockEnabled = true,
|
||||
encryptCacheEnabled = settings.encryptCache,
|
||||
encryptedCacheProtected = settings.encryptCache || authSealed,
|
||||
deviceSecure = appLockManager.isDeviceSecure(),
|
||||
keyInvalidated = databaseKeyCipher.isInvalidated(),
|
||||
)
|
||||
@@ -155,6 +165,9 @@ class AppLockViewModel @Inject constructor(
|
||||
LockAction.PROCEED -> _uiState.value = AppLockUiState.Unlocked
|
||||
|
||||
LockAction.DISABLE_APP_LOCK -> {
|
||||
// Only reachable when NO auth-sealed passphrase exists (encryptedCacheProtected
|
||||
// above ORs the seal in), so dropping the gate here can never orphan a seal or
|
||||
// strand a still-encrypted cache — those states land on CLEAR_AND_DISABLE.
|
||||
settingsRepository.setAppLock(false)
|
||||
_uiState.value = AppLockUiState.Unlocked
|
||||
}
|
||||
|
||||
@@ -3,11 +3,8 @@ package org.libremail.ui.mailbox
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -16,7 +13,6 @@ import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -142,16 +138,6 @@ private fun AccountSwitcher(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = if (current.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
// #362: flag the switched-to account when its auth circuit has latched (remove-and-re-add).
|
||||
if (current.authError != null) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.account_auth_error_icon_description),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.drawer_switch_account))
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
@@ -163,18 +149,6 @@ private fun AccountSwitcher(
|
||||
fontWeight = if (account.id in accountsWithUnread) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
// #362: a red warning glyph marks any errored account in the switcher list too.
|
||||
trailingIcon = if (account.authError != null) {
|
||||
{
|
||||
Icon(
|
||||
Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.account_auth_error_icon_description),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(account.id)
|
||||
expanded = false
|
||||
|
||||
@@ -54,7 +54,6 @@ import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -122,7 +121,6 @@ fun MailboxScreen(
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
|
||||
val isSyncingFolder by viewModel.isSyncingFolder.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val accountAuthError by viewModel.accountAuthError.collectAsStateWithLifecycle()
|
||||
val selectedIds by viewModel.selectedIds.collectAsStateWithLifecycle()
|
||||
val pendingConfirm by viewModel.pendingConfirm.collectAsStateWithLifecycle()
|
||||
val currentFolderRole by viewModel.currentFolderRole.collectAsStateWithLifecycle()
|
||||
@@ -267,9 +265,6 @@ fun MailboxScreen(
|
||||
val accountsById = remember(accounts) { accounts.associateBy { it.id } }
|
||||
val showAccount = selectedAccountId == null && accounts.size >= 2
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// #362: a persistent, non-dismissable banner while the shown account's auth circuit
|
||||
// is latched — mirrors the DB error state, so it clears only when a re-add does.
|
||||
accountAuthError?.let { AccountErrorBanner(message = it) }
|
||||
if (accounts.size >= 2 && selectedFolder == INBOX) {
|
||||
AccountFilterRow(
|
||||
accounts = accounts,
|
||||
@@ -411,34 +406,6 @@ private fun SearchField(query: String, onQueryChange: (String) -> Unit) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A persistent, non-dismissable error banner shown atop the mailbox when the displayed account's auth
|
||||
* circuit has latched (issue #362). Renders the "remove and re-add" message from the account's persisted
|
||||
* error state, so it is present whenever that state is and disappears the moment a re-add clears it — never
|
||||
* a transient snackbar. Uses the error-container role so it reads as an alert in light and dark themes.
|
||||
*/
|
||||
@Composable
|
||||
private fun AccountErrorBanner(message: String) {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DraftsEntry(count: Int, onClick: () -> Unit) {
|
||||
Row(
|
||||
|
||||
@@ -116,19 +116,6 @@ class MailboxViewModel @Inject constructor(
|
||||
.map { counts -> counts.filter { it.count > 0 }.map { it.accountId }.toSet() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())
|
||||
|
||||
/**
|
||||
* #362: the persistent auth-error message shown as a banner atop the mailbox, or null when the
|
||||
* relevant account(s) are healthy. In a per-account view it is the selected account's error; in the
|
||||
* unified view it surfaces the first errored account's message (identical across accounts), so a
|
||||
* latched Yahoo/AOL account is visible whichever way the mailbox is filtered. Sourced from the same
|
||||
* `accounts` flow the row indicators use, so it clears the instant a re-add wipes the error.
|
||||
*/
|
||||
val accountAuthError: StateFlow<String?> =
|
||||
combine(accounts, _selectedAccountId) { accts, selectedId ->
|
||||
val relevant = if (selectedId == null) accts else accts.filter { it.id == selectedId }
|
||||
relevant.firstNotNullOfOrNull { it.authError }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _searchActive = MutableStateFlow(false)
|
||||
val searchActive: StateFlow<Boolean> = _searchActive.asStateFlow()
|
||||
|
||||
|
||||
@@ -6,15 +6,11 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -71,7 +67,6 @@ internal fun AccountReorderList(
|
||||
AccountReorderRow(
|
||||
id = account.id,
|
||||
email = account.email,
|
||||
authError = account.authError,
|
||||
dragging = dragging,
|
||||
offsetY = if (dragging) dragOffsetY.roundToInt() else 0,
|
||||
onMeasured = { rowHeightPx = it },
|
||||
@@ -112,7 +107,6 @@ internal fun commitDrag(current: List<Account>, id: String, dragOffsetY: Float,
|
||||
private fun AccountReorderRow(
|
||||
id: String,
|
||||
email: String,
|
||||
authError: String?,
|
||||
dragging: Boolean,
|
||||
offsetY: Int,
|
||||
onMeasured: (Int) -> Unit,
|
||||
@@ -152,30 +146,7 @@ private fun AccountReorderRow(
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = email, style = MaterialTheme.typography.bodyLarge)
|
||||
// #362: a latched Yahoo/AOL auth failure surfaces here as a persistent red error line, so
|
||||
// the user sees which account is broken (and that removing + re-adding it is the fix).
|
||||
if (authError != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.account_auth_error_icon_description),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = authError,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(text = email, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = stringResource(R.string.account_reorder_handle),
|
||||
|
||||
@@ -288,12 +288,6 @@
|
||||
<string name="account_reorder_handle">Long-press and drag to reorder account</string>
|
||||
<string name="account_remove">Remove account</string>
|
||||
|
||||
<!-- Account auth error (#362): shown on the account row (Settings, drawer switcher) and as a banner
|
||||
atop the mailbox once an account's Yahoo/AOL login has failed enough consecutive times that a
|
||||
credential fix, not a retry, is required. Cleared when the user removes and re-adds the account. -->
|
||||
<string name="account_auth_error_remove_readd">Please remove and re-add this account with valid credentials</string>
|
||||
<string name="account_auth_error_icon_description">Account error</string>
|
||||
|
||||
<!-- Backup -->
|
||||
<string name="settings_backup">Settings Backup</string>
|
||||
<string name="settings_backup_include">Include settings in Android Backup</string>
|
||||
|
||||
@@ -64,6 +64,7 @@ class DatabaseProvisionerTest {
|
||||
// detekt-forbidden, epic #324) so it does not throw "not mocked".
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
|
||||
every { context.getDatabasePath(any()) } returns File("libremail.db")
|
||||
every { DatabaseFiles.clear(any()) } just Runs
|
||||
@@ -78,6 +79,8 @@ class DatabaseProvisionerTest {
|
||||
coEvery { keyStore.resetSealedPassphrase() } just Runs
|
||||
coEvery { keyStore.clearClearPending() } just Runs
|
||||
coEvery { keyStore.resolvePassphrase(any()) } returns PASSPHRASE
|
||||
coEvery { keyStore.hasAuthSealedPassphrase() } returns false
|
||||
coEvery { keyStore.sealWithMaster() } just Runs
|
||||
coEvery { accountDataMigrator.migrateIfNeeded() } just Runs
|
||||
}
|
||||
|
||||
@@ -278,6 +281,46 @@ class DatabaseProvisionerTest {
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
verify(exactly = 1) { DatabaseEncryption.ensurePlaintext(any(), PASSPHRASE) }
|
||||
verify(exactly = 0) { DatabaseEncryption.ensureEncrypted(any(), any()) }
|
||||
// No auth seal exists (the passphrase was master-sealed), so nothing must be resealed.
|
||||
coVerify(exactly = 0) { keyStore.sealWithMaster() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt-on-disable releases a lingering auth seal by resealing under the master key`() = runTest {
|
||||
// Issue #479: after the decrypt-to-plaintext conversion the auth seal is an orphan — nothing
|
||||
// needs it to open the DB, but its presence keeps the app in the transitional window where
|
||||
// losing the auth-bound key forces a needless cache wipe. It must be resealed under the
|
||||
// master key AFTER the file conversion (the passphrase is still in hand on this path).
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = true))
|
||||
every { DatabaseEncryption.isEncrypted(any()) } returns true
|
||||
coEvery { keyStore.hasAuthSealedPassphrase() } returns true
|
||||
|
||||
val mode = provisioner().prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
coVerifyOrder {
|
||||
keyStore.resolvePassphrase(true)
|
||||
DatabaseEncryption.ensurePlaintext(any(), PASSPHRASE)
|
||||
keyStore.sealWithMaster()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed reseal after decrypt-on-disable is non-fatal and still opens plaintext`() = runTest {
|
||||
// The reseal is best-effort: the cache is already plaintext, so a Keystore hiccup must not
|
||||
// fail the open (the lingering seal is handled defensively by the guard and the policy).
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = true))
|
||||
every { DatabaseEncryption.isEncrypted(any()) } returns true
|
||||
coEvery { keyStore.hasAuthSealedPassphrase() } returns true
|
||||
coEvery { keyStore.sealWithMaster() } throws IllegalStateException("keystore busy")
|
||||
|
||||
val mode = provisioner().prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
coVerify(exactly = 1) { keyStore.sealWithMaster() }
|
||||
// Fail soft, never destructive: the seal is left alone rather than reset/wiped.
|
||||
coVerify(exactly = 0) { keyStore.resetSealedPassphrase() }
|
||||
verify(exactly = 0) { DatabaseFiles.clear(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -45,24 +45,6 @@ class MappersTest {
|
||||
assertEquals(account, account.toEntity().toDomain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Account authError round-trips through the entity in both directions (issue 362)`() {
|
||||
val healthy = Account(
|
||||
id = "acct",
|
||||
email = "ada@example.org",
|
||||
displayName = "Ada",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
assertNull(healthy.toEntity().authError, "a healthy account carries no persisted error")
|
||||
assertNull(healthy.toEntity().toDomain().authError)
|
||||
|
||||
val errored = healthy.copy(authError = "Please remove and re-add this account with valid credentials")
|
||||
assertEquals(errored.authError, errored.toEntity().authError, "the error is persisted")
|
||||
assertEquals(errored, errored.toEntity().toDomain(), "and reads back intact")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountEntity toDomain falls back to safe defaults for unknown persisted enum names`() {
|
||||
val entity = AccountEntity(
|
||||
|
||||
@@ -38,14 +38,12 @@ import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedFolder
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
@@ -64,7 +62,6 @@ class AccountRepositoryImplTest {
|
||||
private val draftDao = mockk<DraftDao>(relaxed = true)
|
||||
private val credentialStore = mockk<CredentialStore>(relaxed = true)
|
||||
private val imapClient = mockk<ImapClient>()
|
||||
private val authGate = mockk<AuthThrottleGate>(relaxed = true)
|
||||
private val syncScheduler = mockk<SyncScheduler>(relaxed = true)
|
||||
private val accountSettingsRepository = mockk<AccountSettingsRepository>(relaxed = true)
|
||||
private val mailNotifier = mockk<MailNotifier>(relaxed = true)
|
||||
@@ -79,7 +76,6 @@ class AccountRepositoryImplTest {
|
||||
draftDao = draftDao,
|
||||
credentialStore = credentialStore,
|
||||
imapClient = imapClient,
|
||||
authGate = authGate,
|
||||
syncScheduler = syncScheduler,
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
mailNotifier = mailNotifier,
|
||||
@@ -174,27 +170,6 @@ class AccountRepositoryImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addImapAccount resets a latched auth circuit before the test and clears the account error`() = runTest {
|
||||
val account = account()
|
||||
val entity = slot<AccountEntity>()
|
||||
coEvery { imapClient.listFolders(any()) } returns listOf(
|
||||
FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
|
||||
)
|
||||
coEvery { accountDao.insertAtEnd(capture(entity)) } just Runs
|
||||
|
||||
repository.addImapAccount(account, "app-password").getOrThrow()
|
||||
|
||||
// #362: the in-memory latch is dropped BEFORE the connection test, so a fresh credential logs in
|
||||
// cleanly instead of being refused by a still-latched gate.
|
||||
coVerifyOrder {
|
||||
authGate.onAccountReadded(any())
|
||||
imapClient.listFolders(any())
|
||||
}
|
||||
// ...and the (re)written account row carries a null authError, clearing any persisted error state.
|
||||
assertNull(entity.captured.authError, "a re-add clears the persisted account error")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addOutlookAccount persists the credential before the account row (issue 403)`() = runTest {
|
||||
coEvery { imapClient.listFolders(any()) } returns listOf(
|
||||
|
||||
@@ -1,57 +1,172 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.security
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* [EncryptedCacheGuard.isCacheLocked] is the pure truth table `appLock && encryptCache && !unlocked`:
|
||||
* only when app-lock AND the encrypted cache are both on AND the passphrase session has not been
|
||||
* unlocked would opening the Room DB block on authentication, so background work must defer. Every
|
||||
* other combination is safe to proceed. Both collaborators are DataStore/in-memory only, so this is a
|
||||
* clean JVM test.
|
||||
* [EncryptedCacheGuard.isCacheLocked] mirrors [DatabaseKeyStore.resolvePassphrase]'s blocking
|
||||
* branches: locked iff opening the Room DB would actually suspend on the user authenticating. The
|
||||
* decision is keyed off which seal EXISTS ([DatabaseKeyStore.sealState]) plus — in the transitional
|
||||
* [SealState.AUTH]-with-encryptCache-off window (issue #479) — whether the on-disk file is still
|
||||
* encrypted. Deriving it from the appLock/encryptCache settings alone answered wrongly in both
|
||||
* transitional states: workers parked forever inside `provideDatabase` when the setting was off but
|
||||
* the DB still auth-sealed, and sync/push/send stalled needlessly while the seal was still MASTER.
|
||||
*
|
||||
* The file-header check runs against real temp files (a genuine 16-byte SQLite header vs. junk
|
||||
* bytes), so [org.libremail.data.local.DatabaseEncryption.isEncrypted] is exercised for real.
|
||||
*/
|
||||
class EncryptedCacheGuardTest {
|
||||
|
||||
private val settingsRepository = mockk<SettingsRepository>()
|
||||
private val session = mockk<PassphraseSession>()
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private suspend fun cacheLocked(appLock: Boolean, encryptCache: Boolean, unlocked: Boolean): Boolean {
|
||||
private val settingsRepository = mockk<SettingsRepository>()
|
||||
private val keyStore = mockk<DatabaseKeyStore>()
|
||||
private val session = mockk<PassphraseSession>()
|
||||
private val context = mockk<Context>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// android.util.Log is a no-op stub that throws "not mocked" in JVM tests; the guard
|
||||
// breadcrumbs the transitional divergence through AppLog, which always forwards to it.
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private suspend fun cacheLocked(
|
||||
appLock: Boolean = true,
|
||||
encryptCache: Boolean = true,
|
||||
unlocked: Boolean = false,
|
||||
sealState: SealState = SealState.NONE,
|
||||
dbFile: File = plaintextDbFile(),
|
||||
): Boolean {
|
||||
every { settingsRepository.settings } returns
|
||||
flowOf(AppSettings(appLock = appLock, encryptCache = encryptCache))
|
||||
every { session.isUnlocked() } returns unlocked
|
||||
return EncryptedCacheGuard(settingsRepository, session).isCacheLocked()
|
||||
coEvery { keyStore.sealState() } returns sealState
|
||||
every { context.getDatabasePath(any()) } returns dbFile
|
||||
return EncryptedCacheGuard(context, settingsRepository, keyStore, session).isCacheLocked()
|
||||
}
|
||||
|
||||
/** A file with a genuine plaintext-SQLite 16-byte header (padded past it). */
|
||||
private fun plaintextDbFile(): File = tmp.newFile().apply {
|
||||
writeBytes("SQLite format 3".toByteArray(Charsets.US_ASCII) + byteArrayOf(0) + ByteArray(32))
|
||||
}
|
||||
|
||||
/** A file whose header is NOT the SQLite magic — what a SQLCipher-encrypted cache looks like. */
|
||||
private fun encryptedDbFile(): File = tmp.newFile().apply {
|
||||
writeBytes(ByteArray(48) { 0x5A })
|
||||
}
|
||||
|
||||
// --- SealState.NONE: the original settings-driven rows are preserved -------------------------
|
||||
|
||||
@Test
|
||||
fun `no seal - locked only while both settings ask for an auth-armed cache`() = runTest {
|
||||
// First-time arm pending: resolvePassphrase(appLock=true) would await the session.
|
||||
assertTrue(cacheLocked(appLock = true, encryptCache = true, sealState = SealState.NONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locked only when app-lock and encrypted cache are on and the session is not unlocked`() = runTest {
|
||||
assertTrue(cacheLocked(appLock = true, encryptCache = true, unlocked = false))
|
||||
fun `no seal - not locked once the passphrase session is unlocked`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = true, encryptCache = true, unlocked = true, sealState = SealState.NONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not locked once the passphrase session is unlocked`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = true, encryptCache = true, unlocked = true))
|
||||
fun `no seal - not locked when the cache is not encrypted`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = true, encryptCache = false, sealState = SealState.NONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not locked when the cache is not encrypted`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = true, encryptCache = false, unlocked = false))
|
||||
fun `no seal - not locked when app-lock is off`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = false, encryptCache = true, sealState = SealState.NONE))
|
||||
}
|
||||
|
||||
// --- SealState.MASTER: auto-unwraps without authentication — never locked --------------------
|
||||
|
||||
@Test
|
||||
fun `master seal - not locked even with both settings on and the session locked`() = runTest {
|
||||
// Issue #479 (transitional case B): app-lock/encryptCache just enabled mid-session; the
|
||||
// passphrase is still master-sealed until the next authentication reseals it, so the DB
|
||||
// opens auth-free — background sync/push/send must NOT stall on the settings pair.
|
||||
assertFalse(cacheLocked(appLock = true, encryptCache = true, sealState = SealState.MASTER))
|
||||
}
|
||||
|
||||
// --- SealState.AUTH: locked while the session is, including the setting-off window -----------
|
||||
|
||||
@Test
|
||||
fun `auth seal - locked while the setting is on and the session is locked`() = runTest {
|
||||
assertTrue(cacheLocked(appLock = true, encryptCache = true, sealState = SealState.AUTH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not locked when app-lock is off`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = false, encryptCache = true, unlocked = false))
|
||||
fun `auth seal - not locked once the session is unlocked`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = true, encryptCache = true, unlocked = true, sealState = SealState.AUTH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not locked when neither app-lock nor encrypted cache is on`() = runTest {
|
||||
assertFalse(cacheLocked(appLock = false, encryptCache = false, unlocked = true))
|
||||
fun `auth seal - locked when the setting is off but the cache file is still encrypted`() = runTest {
|
||||
// Issue #479 (transitional case A): encryptCache was toggled off but the decrypt runs only
|
||||
// at the next cold start. Opening now would suspend in resolvePassphrase on session.await(),
|
||||
// so the guard must report locked — the old settings-derived answer said "unlocked" and let
|
||||
// workers park forever inside provideDatabase.
|
||||
assertTrue(
|
||||
cacheLocked(
|
||||
appLock = true,
|
||||
encryptCache = false,
|
||||
sealState = SealState.AUTH,
|
||||
dbFile = encryptedDbFile(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth seal - not locked when the setting is off and the cache file is already plaintext`() = runTest {
|
||||
// A lingering (orphaned) auth seal after the decrypt-on-disable conversion: the plaintext
|
||||
// open needs no passphrase, so background work must not be stalled by the leftover seal.
|
||||
assertFalse(
|
||||
cacheLocked(
|
||||
appLock = true,
|
||||
encryptCache = false,
|
||||
sealState = SealState.AUTH,
|
||||
dbFile = plaintextDbFile(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth seal - locked even when app-lock is off while the file is still encrypted`() = runTest {
|
||||
// The desync state the old formula wedged on: app-lock already off but the cache is still
|
||||
// auth-sealed and encrypted. resolvePassphrase keys off the seal, so an open WOULD suspend;
|
||||
// deferring (retry) is the only safe answer.
|
||||
assertTrue(
|
||||
cacheLocked(
|
||||
appLock = false,
|
||||
encryptCache = false,
|
||||
sealState = SealState.AUTH,
|
||||
dbFile = encryptedDbFile(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ import kotlin.test.assertEquals
|
||||
|
||||
class KeyInvalidationPolicyTest {
|
||||
|
||||
// `cacheProtected` is decide()'s `encryptedCacheProtected` input: derived by callers as
|
||||
// "encryptCache setting ON, or an auth-sealed passphrase still exists" (issue #479) — never the
|
||||
// raw setting, which can already be off while the on-disk cache is still auth-sealed.
|
||||
private fun decide(
|
||||
appLock: Boolean = true,
|
||||
encrypt: Boolean = true,
|
||||
cacheProtected: Boolean = true,
|
||||
secure: Boolean = true,
|
||||
invalidated: Boolean = false,
|
||||
) = KeyInvalidationPolicy.decide(appLock, encrypt, secure, invalidated)
|
||||
) = KeyInvalidationPolicy.decide(appLock, cacheProtected, secure, invalidated)
|
||||
|
||||
@Test
|
||||
fun `app-lock off proceeds`() {
|
||||
@@ -25,41 +28,53 @@ class KeyInvalidationPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removed with encrypted cache clears and disables`() {
|
||||
fun `lock removed with a protected cache clears and disables`() {
|
||||
// The auth-bound passphrase is unrecoverable; wipe the cache and drop the gate.
|
||||
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, encrypt = true))
|
||||
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, cacheProtected = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removed without encrypted cache just disables`() {
|
||||
assertEquals(LockAction.DISABLE_APP_LOCK, decide(secure = false, encrypt = false))
|
||||
fun `lock removed while the cache is still auth-sealed clears even with the setting off`() {
|
||||
// The issue-#479 transitional window: encryptCache was just toggled OFF (setting written,
|
||||
// on-disk decrypt deferred to the next cold start, SEALED_AUTH still present) and the user
|
||||
// removes the device lock. The caller derives cacheProtected from setting-OR-seal, so this
|
||||
// state lands on the same protected row — CLEAR_AND_DISABLE — never DISABLE_APP_LOCK, which
|
||||
// would strand a permanently unreadable cache behind a gate that no longer authenticates.
|
||||
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, cacheProtected = true, invalidated = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric re-enrollment with encrypted cache clears and re-requires auth`() {
|
||||
assertEquals(LockAction.CLEAR_AND_REQUIRE_AUTH, decide(invalidated = true, encrypt = true))
|
||||
fun `lock removed without a protected cache just disables`() {
|
||||
assertEquals(LockAction.DISABLE_APP_LOCK, decide(secure = false, cacheProtected = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric re-enrollment without encrypted cache re-requires auth without clearing`() {
|
||||
fun `biometric re-enrollment with a protected cache clears and re-requires auth`() {
|
||||
assertEquals(LockAction.CLEAR_AND_REQUIRE_AUTH, decide(invalidated = true, cacheProtected = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `biometric re-enrollment without a protected cache re-requires auth without clearing`() {
|
||||
// Nothing encrypted to lose; a fresh key is minted on the next successful unlock.
|
||||
assertEquals(LockAction.REQUIRE_AUTH, decide(invalidated = true, encrypt = false))
|
||||
assertEquals(LockAction.REQUIRE_AUTH, decide(invalidated = true, cacheProtected = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removal takes precedence over key-invalidation flag`() {
|
||||
// Both true: the device being insecure dominates (can't authenticate at all).
|
||||
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, invalidated = true, encrypt = true))
|
||||
assertEquals(LockAction.CLEAR_AND_DISABLE, decide(secure = false, invalidated = true, cacheProtected = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every one of the 16 input combinations maps to its pinned action`() {
|
||||
// The complete truth table for decide(appLock, encrypt, secure, invalidated): all 2^4 = 16 rows
|
||||
// listed explicitly, so a mutation of ANY branch is caught — most importantly the common
|
||||
// (on, *, secure, valid) rows, whose silent flip to PROCEED would be a lock bypass. The
|
||||
// completeness guard below fails if a row is ever dropped, keeping the table exhaustive.
|
||||
// The complete truth table for decide(appLock, cacheProtected, secure, invalidated): all
|
||||
// 2^4 = 16 rows listed explicitly, so a mutation of ANY branch is caught — most importantly
|
||||
// the common (on, *, secure, valid) rows, whose silent flip to PROCEED would be a lock
|
||||
// bypass. The completeness guard below fails if a row is ever dropped, keeping the table
|
||||
// exhaustive. `cacheProtected` is setting-OR-seal (issue #479), so the "protected" rows also
|
||||
// pin the transitional encryptCache-off-but-still-auth-sealed states.
|
||||
//
|
||||
// Columns: appLock, encrypt, secure, invalidated -> expected action.
|
||||
// Columns: appLock, cacheProtected, secure, invalidated -> expected action.
|
||||
val table = listOf(
|
||||
// App-lock OFF: always PROCEED, whatever the other three inputs are.
|
||||
Case(false, false, false, false, LockAction.PROCEED),
|
||||
@@ -70,12 +85,13 @@ class KeyInvalidationPolicyTest {
|
||||
Case(false, true, false, true, LockAction.PROCEED),
|
||||
Case(false, true, true, false, LockAction.PROCEED),
|
||||
Case(false, true, true, true, LockAction.PROCEED),
|
||||
// App-lock ON, device NOT secure (lock removed): clear+disable iff a cache exists, else disable.
|
||||
// App-lock ON, device NOT secure (lock removed): clear+disable iff a cache is protected
|
||||
// (setting on OR still auth-sealed), else disable.
|
||||
Case(true, true, false, false, LockAction.CLEAR_AND_DISABLE),
|
||||
Case(true, true, false, true, LockAction.CLEAR_AND_DISABLE),
|
||||
Case(true, false, false, false, LockAction.DISABLE_APP_LOCK),
|
||||
Case(true, false, false, true, LockAction.DISABLE_APP_LOCK),
|
||||
// App-lock ON, secure, key invalidated: clear+re-auth iff a cache exists, else just re-auth.
|
||||
// App-lock ON, secure, key invalidated: clear+re-auth iff a cache is protected, else just re-auth.
|
||||
Case(true, true, true, true, LockAction.CLEAR_AND_REQUIRE_AUTH),
|
||||
Case(true, false, true, true, LockAction.REQUIRE_AUTH),
|
||||
// App-lock ON, secure, key valid: the common case — require auth, no wipe.
|
||||
@@ -83,19 +99,19 @@ class KeyInvalidationPolicyTest {
|
||||
Case(true, false, true, false, LockAction.REQUIRE_AUTH),
|
||||
)
|
||||
|
||||
// Exhaustiveness: exactly the 16 distinct (appLock, encrypt, secure, invalidated) combinations.
|
||||
// Exhaustiveness: exactly the 16 distinct (appLock, cacheProtected, secure, invalidated) combinations.
|
||||
assertEquals(16, table.size, "the table must list all 2^4 input combinations")
|
||||
assertEquals(
|
||||
16,
|
||||
table.map { listOf(it.appLock, it.encrypt, it.secure, it.invalidated) }.toSet().size,
|
||||
table.map { listOf(it.appLock, it.cacheProtected, it.secure, it.invalidated) }.toSet().size,
|
||||
"every row must be a distinct input combination",
|
||||
)
|
||||
|
||||
for (case in table) {
|
||||
assertEquals(
|
||||
case.expected,
|
||||
decide(case.appLock, case.encrypt, case.secure, case.invalidated),
|
||||
"decide(appLock=${case.appLock}, encrypt=${case.encrypt}, " +
|
||||
decide(case.appLock, case.cacheProtected, case.secure, case.invalidated),
|
||||
"decide(appLock=${case.appLock}, cacheProtected=${case.cacheProtected}, " +
|
||||
"secure=${case.secure}, invalidated=${case.invalidated})",
|
||||
)
|
||||
}
|
||||
@@ -103,7 +119,7 @@ class KeyInvalidationPolicyTest {
|
||||
|
||||
private data class Case(
|
||||
val appLock: Boolean,
|
||||
val encrypt: Boolean,
|
||||
val cacheProtected: Boolean,
|
||||
val secure: Boolean,
|
||||
val invalidated: Boolean,
|
||||
val expected: LockAction,
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.mail.AuthCadencePolicy
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.ProviderAuthPolicy
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* [markAccountErroredIfLatched] (issue #362) must persist the user-facing "remove and re-add" error onto
|
||||
* an account whose proactive auth circuit has **latched** — exactly once, PII-free — and report the latch
|
||||
* so the caller stops syncing; and it must be a no-op for a still-ramping or healthy account.
|
||||
*/
|
||||
class AccountAuthErrorTest {
|
||||
|
||||
private val logBuffer = RingLogBuffer()
|
||||
|
||||
/** A gate that treats the test host as a gated Yahoo/AOL account, latching after the threshold. */
|
||||
private fun latchingGate(): AuthThrottleGate = AuthThrottleGate(
|
||||
nowMillis = { 0L },
|
||||
random = { 0.0 },
|
||||
policyForHost = { ProviderAuthPolicy.forHost(YAHOO_HOST) },
|
||||
)
|
||||
|
||||
private fun params() = ImapConnectionParams(YAHOO_HOST, PORT, MailSecurity.SSL_TLS, "user@yahoo.com", "s", false)
|
||||
|
||||
private val account = Account(
|
||||
id = "acct",
|
||||
email = "user@yahoo.com",
|
||||
displayName = "User",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig(YAHOO_HOST, PORT, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.mail.yahoo.com", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private fun context(): Context = mockk<Context>().apply {
|
||||
every { getString(R.string.account_auth_error_remove_readd) } returns MESSAGE
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||
AppLog.install(logBuffer)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkAll()
|
||||
|
||||
@Test
|
||||
fun `a latched account is stamped with the remove-and-re-add error and reported latched`() = runTest {
|
||||
val gate = latchingGate()
|
||||
val p = params()
|
||||
repeat(THRESHOLD) { gate.onAuthFailure(p) }
|
||||
logBuffer.clear() // isolate the helper's breadcrumb from the gate's own latch breadcrumbs
|
||||
val dao = mockk<AccountDao>()
|
||||
coEvery { dao.setAuthError("acct", MESSAGE) } returns 1
|
||||
|
||||
val latched = markAccountErroredIfLatched(gate, dao, context(), account, p)
|
||||
|
||||
assertTrue(latched, "a latched account is reported so the caller stops syncing it")
|
||||
coVerify(exactly = 1) { dao.setAuthError("acct", MESSAGE) }
|
||||
val messages = logBuffer.snapshot().map { it.message }
|
||||
assertTrue(messages.any { it.contains("account marked errored") }, "a PII-free breadcrumb is logged")
|
||||
messages.forEach {
|
||||
assertFalse(it.contains("user@yahoo.com"), it)
|
||||
assertFalse(it.contains(YAHOO_HOST), it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an idempotent no-op write is not logged but still reports latched`() = runTest {
|
||||
val gate = latchingGate()
|
||||
val p = params()
|
||||
repeat(THRESHOLD) { gate.onAuthFailure(p) }
|
||||
logBuffer.clear() // isolate the helper's (non-)logging from the gate's own latch breadcrumbs
|
||||
val dao = mockk<AccountDao>()
|
||||
// The conditional UPDATE changed 0 rows (the message is already stored) — no re-log.
|
||||
coEvery { dao.setAuthError("acct", MESSAGE) } returns 0
|
||||
|
||||
val latched = markAccountErroredIfLatched(gate, dao, context(), account, p)
|
||||
|
||||
assertTrue(latched)
|
||||
assertTrue(logBuffer.snapshot().isEmpty(), "a no-op re-write must not re-log the latch")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a still-ramping account is not errored`() = runTest {
|
||||
val gate = latchingGate()
|
||||
val p = params()
|
||||
gate.onAuthFailure(p) // one failure — below the threshold, still ramping (not latched)
|
||||
val dao = mockk<AccountDao>()
|
||||
|
||||
val latched = markAccountErroredIfLatched(gate, dao, context(), account, p)
|
||||
|
||||
assertFalse(latched, "a ramping account keeps retrying, it is not errored")
|
||||
coVerify(exactly = 0) { dao.setAuthError(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a healthy non-gated account is never errored`() = runTest {
|
||||
// A disabled policy (non-Yahoo host) never latches, so this is a total no-op.
|
||||
val gate = AuthThrottleGate(
|
||||
nowMillis = { 0L },
|
||||
random = { 0.0 },
|
||||
policyForHost = { AuthCadencePolicy.DISABLED },
|
||||
)
|
||||
val p = params()
|
||||
repeat(THRESHOLD * 2) { gate.onAuthFailure(p) }
|
||||
val dao = mockk<AccountDao>()
|
||||
|
||||
assertFalse(markAccountErroredIfLatched(gate, dao, context(), account, p))
|
||||
coVerify(exactly = 0) { dao.setAuthError(any(), any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val YAHOO_HOST = "imap.mail.yahoo.com"
|
||||
const val PORT = 993
|
||||
const val THRESHOLD = 4
|
||||
const val MESSAGE = "Please remove and re-add this account with valid credentials"
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,8 @@ import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.mail.ProviderAuthPolicy
|
||||
import org.libremail.power.BatteryStatus
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import org.libremail.reporting.AppLog
|
||||
@@ -534,89 +532,6 @@ class MailBackfillerTest {
|
||||
assertTrue(logBuffer.snapshot().any { it.message.startsWith("backfill skip acct:") })
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #362 composes with #360/#356 by reusing the same skip: an account inside its proactive
|
||||
* *auth*-backoff window is skipped exactly as a reactively-throttled one is — no server call, no
|
||||
* `moreWork` (so [BackfillPacer] does not spin a cooldown on it), and the account is left alone so
|
||||
* backfill never nudges a Yahoo/AOL account toward its ~1-hour lockout.
|
||||
*/
|
||||
@Test
|
||||
fun `an account inside its auth-backoff window is skipped, not paged`() = runTest {
|
||||
cached += fetchedMessage(uid = "60").toEntity("acct", "INBOX")
|
||||
val imapClient = mockk<ImapClient>(relaxed = true)
|
||||
// Treat the (127.0.0.1) test host as a gated Yahoo/AOL account and arm the backoff on its identity.
|
||||
val authGate = AuthThrottleGate(
|
||||
nowMillis = { 0L },
|
||||
random = { 0.0 },
|
||||
policyForHost = { ProviderAuthPolicy.forHost("imap.mail.yahoo.com") },
|
||||
)
|
||||
authGate.onAuthFailure(params())
|
||||
|
||||
val moreWork = backfiller(AccountSettings("acct"), imapClient = imapClient, authGate = authGate).runBackfill()
|
||||
|
||||
assertFalse(moreWork, "a slice whose only account is auth-blocked reports done, not more-work")
|
||||
coVerify(exactly = 0) { imapClient.fetchOlderThan(any(), any(), any(), any()) }
|
||||
assertTrue(authGate.isAuthBlocked(params()), "the account is still auth-backing-off")
|
||||
assertTrue(
|
||||
logBuffer.snapshot().any {
|
||||
it.message.startsWith("backfill skip acct:") && it.message.contains("auth backing off")
|
||||
},
|
||||
"a PII-free auth-skip breadcrumb is recorded",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #362 fail-loud stop: an account already persisted as errored (its Yahoo/AOL auth circuit has
|
||||
* latched) is skipped entirely — no server call, no `moreWork` — durably across restarts, since the
|
||||
* skip reads the persisted [AccountEntity.authError] rather than only the in-memory gate. It stays
|
||||
* skipped until the user re-adds the account, which clears the error.
|
||||
*/
|
||||
@Test
|
||||
fun `an errored account is skipped, not paged`() = runTest {
|
||||
cached += fetchedMessage(uid = "60").toEntity("acct", "INBOX")
|
||||
val imapClient = mockk<ImapClient>(relaxed = true)
|
||||
val errored = accountEntity.copy(authError = "Please remove and re-add this account with valid credentials")
|
||||
|
||||
val moreWork = backfiller(AccountSettings("acct"), imapClient = imapClient, account = errored).runBackfill()
|
||||
|
||||
assertFalse(moreWork, "an errored account reports done, not more-work")
|
||||
coVerify(exactly = 0) { imapClient.fetchOlderThan(any(), any(), any(), any()) }
|
||||
assertTrue(
|
||||
logBuffer.snapshot().any {
|
||||
it.message.startsWith("backfill skip acct:") && it.message.contains("errored")
|
||||
},
|
||||
"a PII-free errored-skip breadcrumb is recorded",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mid-slice latch (#362): a gate that has just latched (via any path) but whose account row is not
|
||||
* yet stamped is marked errored (markAccountErroredIfLatched) and skipped — no page fetched, no more-work.
|
||||
*/
|
||||
@Test
|
||||
fun `a freshly latched account is marked errored and skipped, not paged`() = runTest {
|
||||
cached += fetchedMessage(uid = "60").toEntity("acct", "INBOX")
|
||||
val imapClient = mockk<ImapClient>(relaxed = true)
|
||||
val authGate = AuthThrottleGate(
|
||||
nowMillis = { 0L },
|
||||
random = { 0.0 },
|
||||
policyForHost = { ProviderAuthPolicy.forHost("imap.mail.yahoo.com") },
|
||||
)
|
||||
repeat(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD) { authGate.onAuthFailure(params()) }
|
||||
|
||||
val moreWork = backfiller(AccountSettings("acct"), imapClient = imapClient, authGate = authGate).runBackfill()
|
||||
|
||||
assertFalse(moreWork, "a latched account reports done, not more-work")
|
||||
coVerify(exactly = 0) { imapClient.fetchOlderThan(any(), any(), any(), any()) }
|
||||
assertTrue(authGate.isAuthLatched(params()), "the account is latched")
|
||||
assertTrue(
|
||||
logBuffer.snapshot().any {
|
||||
it.message.startsWith("backfill skip acct:") && it.message.contains("latched")
|
||||
},
|
||||
"a PII-free latched-skip breadcrumb is recorded",
|
||||
)
|
||||
}
|
||||
|
||||
// --- issue #363: iCloud Mail connection cap --------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -914,12 +829,10 @@ class MailBackfillerTest {
|
||||
interactiveGate: InteractiveImapGate = InteractiveImapGate(),
|
||||
icloudConnectionLimiter: IcloudConnectionLimiter = IcloudConnectionLimiter(),
|
||||
bandwidthTracker: GmailBandwidthTracker = GmailBandwidthTracker(),
|
||||
authGate: AuthThrottleGate = AuthThrottleGate(),
|
||||
account: AccountEntity = accountEntity,
|
||||
): MailBackfiller {
|
||||
val accountDao = mockk<AccountDao>()
|
||||
coEvery { accountDao.getAll() } returns listOf(account)
|
||||
coEvery { accountDao.setAuthError(any(), any()) } returns 1
|
||||
|
||||
val messageDao = mockk<MessageDao>(relaxed = true)
|
||||
coEvery { messageDao.insertNew(any()) } answers {
|
||||
@@ -976,7 +889,6 @@ class MailBackfillerTest {
|
||||
interactiveGate = interactiveGate,
|
||||
icloudConnectionLimiter = icloudConnectionLimiter,
|
||||
bandwidthTracker = bandwidthTracker,
|
||||
authGate = authGate,
|
||||
).also {
|
||||
lastMessageDao = messageDao
|
||||
lastMailRepository = mailRepository
|
||||
|
||||
@@ -29,7 +29,6 @@ import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.power.BatteryStatus
|
||||
@@ -205,7 +204,6 @@ class MailMaintenanceGateTest {
|
||||
interactiveGate = InteractiveImapGate(),
|
||||
icloudConnectionLimiter = IcloudConnectionLimiter(),
|
||||
bandwidthTracker = GmailBandwidthTracker(),
|
||||
authGate = AuthThrottleGate(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.power.BatteryStatus
|
||||
@@ -333,7 +332,6 @@ class MailSyncConcurrencyTest {
|
||||
mailRepository = mockk(relaxed = true),
|
||||
throttleGate = AccountThrottleGate(),
|
||||
bandwidthTracker = GmailBandwidthTracker(),
|
||||
authGate = AuthThrottleGate(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -367,7 +365,6 @@ class MailSyncConcurrencyTest {
|
||||
interactiveGate = InteractiveImapGate(),
|
||||
icloudConnectionLimiter = IcloudConnectionLimiter(),
|
||||
bandwidthTracker = GmailBandwidthTracker(),
|
||||
authGate = AuthThrottleGate(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,9 @@ import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.mail.ProviderAuthPolicy
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import org.libremail.power.BatteryStatus
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
@@ -99,10 +96,9 @@ class MailSyncerTest {
|
||||
fetched: List<FetchedMessage> = emptyList(),
|
||||
throttleGate: AccountThrottleGate = AccountThrottleGate(),
|
||||
bandwidthTracker: GmailBandwidthTracker = GmailBandwidthTracker(),
|
||||
authGate: AuthThrottleGate = AuthThrottleGate(),
|
||||
accountEntity: AccountEntity = account,
|
||||
): MailSyncer {
|
||||
val accountDao = mockk<AccountDao>(relaxed = true)
|
||||
val accountDao = mockk<AccountDao>()
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity
|
||||
val messageDao = mockk<MessageDao>(relaxed = true)
|
||||
coEvery { messageDao.getSyncedIds(any(), any()) } returns emptyList()
|
||||
@@ -112,7 +108,7 @@ class MailSyncerTest {
|
||||
coEvery { imapClient.fetchRecent(any(), any(), any()) } returns fetched
|
||||
lastImapClient = imapClient
|
||||
val connectionFactory = mockk<MailConnectionFactory>()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns realParams()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns mockk<ImapConnectionParams>()
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
coEvery { settingsRepository.fetchPolicy() } returns policy
|
||||
every { settingsRepository.settings } returns flowOf(globalSettings)
|
||||
@@ -131,71 +127,12 @@ class MailSyncerTest {
|
||||
mailRepository = mailRepository,
|
||||
throttleGate = throttleGate,
|
||||
bandwidthTracker = bandwidthTracker,
|
||||
authGate = authGate,
|
||||
)
|
||||
}
|
||||
|
||||
/** A real (non-mock) params object, so the proactive auth gate can key by host|port|username (#362). */
|
||||
private fun realParams() = ImapConnectionParams(
|
||||
host = "imap.example.org",
|
||||
port = 993,
|
||||
security = MailSecurity.SSL_TLS,
|
||||
username = "a@example.org",
|
||||
secret = "secret",
|
||||
useXoauth2 = false,
|
||||
)
|
||||
|
||||
private fun batteryProvider(battery: BatteryStatus): BatteryStatusProvider =
|
||||
mockk<BatteryStatusProvider> { every { current() } returns battery }
|
||||
|
||||
/**
|
||||
* Issue #362 fail-loud stop: an account already persisted as errored (its Yahoo/AOL auth circuit has
|
||||
* latched) is skipped entirely — no login attempt, no fetch — durably across restarts, since the skip
|
||||
* reads the persisted [AccountEntity.authError] rather than the in-memory gate. The sync still reports
|
||||
* success (contributes 0) so a healthy sibling account is unaffected.
|
||||
*/
|
||||
@Test
|
||||
fun `an errored account is skipped without any fetch`() = runTest {
|
||||
val syncer = syncer(
|
||||
FetchPolicy.ON_DEMAND,
|
||||
mockk(relaxed = true),
|
||||
accountEntity = account.copy(authError = "Please remove and re-add this account with valid credentials"),
|
||||
)
|
||||
|
||||
val result = syncer.syncFolder("acct", "INBOX")
|
||||
|
||||
assertEquals(0, result.getOrNull(), "an errored account contributes nothing and does not fail the sync")
|
||||
coVerify(exactly = 0) { lastImapClient.fetchRecent(any(), any(), any()) }
|
||||
assertTrue(
|
||||
logBuffer.snapshot().any { it.message.startsWith("sync skip acct:") && it.message.contains("errored") },
|
||||
"a PII-free skip breadcrumb is recorded",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mid-sync latch (#362): when the gate has already latched (e.g. via a prior IDLE/backfill failure)
|
||||
* but the account row is not yet stamped, the next sync marks it errored via [markAccountErroredIfLatched]
|
||||
* and skips the login rather than driving one the gate would only refuse.
|
||||
*/
|
||||
@Test
|
||||
fun `a freshly latched account is marked errored and skipped without a fetch`() = runTest {
|
||||
val gate = AuthThrottleGate(
|
||||
nowMillis = { 0L },
|
||||
random = { 0.0 },
|
||||
policyForHost = { ProviderAuthPolicy.forHost("imap.mail.yahoo.com") },
|
||||
)
|
||||
repeat(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD) { gate.onAuthFailure(realParams()) }
|
||||
val syncer = syncer(FetchPolicy.ON_DEMAND, mockk(relaxed = true), authGate = gate)
|
||||
|
||||
val result = syncer.syncFolder("acct", "INBOX")
|
||||
|
||||
assertEquals(0, result.getOrNull())
|
||||
coVerify(exactly = 0) { lastImapClient.fetchRecent(any(), any(), any()) }
|
||||
assertTrue(
|
||||
logBuffer.snapshot().any { it.message.startsWith("sync skip acct:") && it.message.contains("latched") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ALWAYS policy prefetches unfetched messages after the header sync`() = runTest {
|
||||
val repo = mockk<MailRepository>()
|
||||
@@ -423,7 +360,7 @@ class MailSyncerTest {
|
||||
FetchedMessage("1", "Ada", "ada@example.org", "Hi", 1_000L, isRead = false, isFlagged = false),
|
||||
)
|
||||
val connectionFactory = mockk<MailConnectionFactory>()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns realParams()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns mockk<ImapConnectionParams>()
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
coEvery { settingsRepository.isNewMailNotificationsEnabled() } returns globalEnabled
|
||||
coEvery { settingsRepository.fetchPolicy() } returns FetchPolicy.ON_DEMAND
|
||||
@@ -444,7 +381,6 @@ class MailSyncerTest {
|
||||
mailRepository = mockk(relaxed = true),
|
||||
throttleGate = AccountThrottleGate(),
|
||||
bandwidthTracker = GmailBandwidthTracker(),
|
||||
authGate = AuthThrottleGate(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -509,7 +445,7 @@ class MailSyncerTest {
|
||||
)
|
||||
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 realParams()
|
||||
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
|
||||
@@ -529,7 +465,6 @@ class MailSyncerTest {
|
||||
mailRepository = mockk(relaxed = true),
|
||||
throttleGate = AccountThrottleGate(),
|
||||
bandwidthTracker = GmailBandwidthTracker(),
|
||||
authGate = AuthThrottleGate(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The pure proactive auth-backoff schedule (issue #362): an exponential *ramp* (equal jitter, capped)
|
||||
* up to the circuit-open threshold, then a long *fixed* open-circuit window — "back off long and stop".
|
||||
* A disabled policy never blocks. Deterministic (the caller supplies the jitter draw), so no clock or
|
||||
* randomness leaks into the assertions.
|
||||
*/
|
||||
class AuthBackoffTest {
|
||||
|
||||
/** A small controlled policy so the ramp/cap arithmetic reads clearly (base 1s, cap 8s, circuit 60s). */
|
||||
private val policy = AuthCadencePolicy(
|
||||
enabled = true,
|
||||
baseBackoffMillis = 1_000L,
|
||||
maxBackoffMillis = 8_000L,
|
||||
circuitOpenThreshold = 4,
|
||||
circuitOpenMillis = 60_000L,
|
||||
maxConcurrentConnections = 5,
|
||||
folderIndexCap = 10_000,
|
||||
)
|
||||
|
||||
/** random = 0.0 selects the lower jitter bound (capped/2); random = 1.0 selects the upper (capped). */
|
||||
private fun low(failures: Int, p: AuthCadencePolicy = policy) = AuthBackoff.blockMillis(p, failures, random = 0.0)
|
||||
private fun high(failures: Int, p: AuthCadencePolicy = policy) = AuthBackoff.blockMillis(p, failures, random = 1.0)
|
||||
|
||||
@Test
|
||||
fun `the ramp doubles per failure at the lower jitter bound`() {
|
||||
assertEquals(policy.baseBackoffMillis / 2, low(1)) // 500ms
|
||||
assertEquals(policy.baseBackoffMillis, low(2)) // 1000ms
|
||||
assertEquals(policy.baseBackoffMillis * 2, low(3)) // 2000ms
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the upper jitter bound of the first failure is the base delay`() {
|
||||
assertEquals(policy.baseBackoffMillis, high(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jitter keeps every ramp draw within the exponential half-window`() {
|
||||
// Failure 2's capped target is 2*base; equal jitter must land in [base, 2*base] for any draw.
|
||||
val lower = policy.baseBackoffMillis
|
||||
val upper = policy.baseBackoffMillis * 2
|
||||
for (thousandths in 0..1000) {
|
||||
val delay = AuthBackoff.blockMillis(policy, consecutiveFailures = 2, random = thousandths / 1000.0)
|
||||
assertTrue(delay in lower..upper, "draw $thousandths gave $delay, outside [$lower,$upper]")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ramp is capped at the policy maximum`() {
|
||||
// Raise the threshold so the ramp actually reaches the cap: failure 5 wants 16*base > cap.
|
||||
val ramped = policy.copy(circuitOpenThreshold = 100)
|
||||
assertEquals(ramped.maxBackoffMillis / 2, low(5, ramped))
|
||||
assertEquals(ramped.maxBackoffMillis, high(5, ramped))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at the threshold the circuit opens to a long fixed window with no jitter`() {
|
||||
// Failure 4 (== threshold) and beyond return the fixed open-circuit window for any jitter draw.
|
||||
assertEquals(policy.circuitOpenMillis, low(4))
|
||||
assertEquals(policy.circuitOpenMillis, high(4))
|
||||
assertEquals(policy.circuitOpenMillis, low(9))
|
||||
assertTrue(
|
||||
policy.circuitOpenMillis > policy.maxBackoffMillis,
|
||||
"the open-circuit window is longer than the ramp cap — we have given up probing",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disabled policy never blocks`() {
|
||||
assertEquals(0L, high(1, AuthCadencePolicy.DISABLED))
|
||||
assertEquals(0L, high(50, AuthCadencePolicy.DISABLED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure count below one is rejected`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
AuthBackoff.blockMillis(policy, consecutiveFailures = 0, random = 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real yahoo schedule keeps every wait under the one-hour lockout`() {
|
||||
// The heart of issue #362: however many times in a row auth fails, no single block reaches the
|
||||
// ~1-hour lockout window — the backoff protects the account without ever matching the punishment.
|
||||
val yahooHost = MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host
|
||||
val yahoo = ProviderAuthPolicy.forHost(yahooHost)
|
||||
for (failures in 1..12) {
|
||||
assertTrue(low(failures, yahoo) < ONE_HOUR_MS, "failure $failures low bound reached the lockout window")
|
||||
assertTrue(high(failures, yahoo) < ONE_HOUR_MS, "failure $failures high bound reached the lockout window")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ONE_HOUR_MS = 60 * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.libremail.data.sync.AccountThrottleGate
|
||||
import org.libremail.data.sync.ThrottleBackoff
|
||||
import org.libremail.data.sync.ThrottleKind
|
||||
import org.libremail.data.sync.ThrottleSignal
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* [AuthThrottleGate] (issue #362) must, for a Yahoo/AOL account: block logins after an auth failure,
|
||||
* escalate rapid consecutive failures without ever reaching the ~1-hour lockout window, open a long
|
||||
* fixed circuit past the threshold, isolate accounts, reset on success, expose an accurate remaining
|
||||
* window (proven against coroutines-test virtual time), and log only PII-free breadcrumbs. It must be a
|
||||
* total no-op for a non-gated host, and it must *compose* with issue #360's reactive gate rather than
|
||||
* fight it.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AuthThrottleGateTest {
|
||||
|
||||
private val logBuffer = RingLogBuffer()
|
||||
|
||||
/** A manual virtual clock for the non-timing tests; [gate] reads it live, so tests advance it by hand. */
|
||||
private var now = 0L
|
||||
|
||||
/** The real Yahoo policy — the production values under test. */
|
||||
private val yahoo = ProviderAuthPolicy.forHost(MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host)
|
||||
|
||||
/** random = 0.0 makes the equal-jitter draw deterministic (always the lower bound of the ramp). */
|
||||
private fun gate(random: () -> Double = { 0.0 }, policyForHost: (String) -> AuthCadencePolicy = { yahoo }) =
|
||||
AuthThrottleGate(nowMillis = { now }, random = random, policyForHost = policyForHost)
|
||||
|
||||
private fun params(user: String = "user@example.org", host: String = "imap.mail.yahoo.com") =
|
||||
ImapConnectionParams(host, PORT, MailSecurity.SSL_TLS, user, secret = "secret", useXoauth2 = false)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// AppLog forwards to android.util.Log, a throwing no-op stub under plain JVM unit tests. Fully
|
||||
// qualified so this file never imports android.util.Log (a detekt-forbidden import, epic #324).
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||
AppLog.install(logBuffer)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkAll()
|
||||
|
||||
@Test
|
||||
fun `onAuthFailure blocks the account and returns the backoff`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val backoff = gate.onAuthFailure(p)
|
||||
|
||||
assertTrue(backoff > 0L)
|
||||
assertTrue(gate.isAuthBlocked(p))
|
||||
assertEquals(backoff, gate.remainingAuthBlockMillis(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid consecutive auth failures escalate the block within the ramp`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val first = gate.onAuthFailure(p)
|
||||
val second = gate.onAuthFailure(p)
|
||||
val third = gate.onAuthFailure(p)
|
||||
|
||||
assertTrue(second > first, "a consecutive failure must back off longer ($second !> $first)")
|
||||
assertTrue(third > second, "and longer again ($third !> $second)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid auth failures never reach the one-hour lockout window`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
// Hammer a wrong credential as fast as an unguarded loop would: every resulting block must stay
|
||||
// well under the ~1-hour lockout it exists to prevent — that is the whole point of issue #362.
|
||||
repeat(RAPID_FAILURES) {
|
||||
val block = gate.onAuthFailure(p)
|
||||
assertTrue(block < ThrottleBackoff.LOCKOUT_BASE_MS, "block $block reached the 1-hour lockout window")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the circuit latches after the threshold and never self-clears`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
repeat(yahoo.circuitOpenThreshold) { gate.onAuthFailure(p) }
|
||||
assertTrue(gate.isAuthLatched(p), "reaching the threshold latches the circuit")
|
||||
assertTrue(gate.isAuthBlocked(p))
|
||||
|
||||
// The OLD self-clearing open-circuit window is gone (issue #362): advancing far past what used to be
|
||||
// the 30-min window must NOT unblock — a wrong app-password does not fix itself, so we stop for good.
|
||||
now += yahoo.circuitOpenMillis * LATCH_ELAPSE_FACTOR
|
||||
assertTrue(gate.isAuthBlocked(p), "a latched circuit never self-clears with time")
|
||||
assertTrue(gate.isAuthLatched(p))
|
||||
|
||||
// A further failure neither escalates nor changes the latch — the state stays frozen.
|
||||
gate.onAuthFailure(p)
|
||||
assertTrue(gate.isAuthLatched(p))
|
||||
assertTrue(gate.isAuthBlocked(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a success does not clear a latched circuit`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
repeat(yahoo.circuitOpenThreshold) { gate.onAuthFailure(p) }
|
||||
assertTrue(gate.isAuthLatched(p))
|
||||
|
||||
// Defensive: even if a login somehow succeeded, a latched account stays errored until a re-add.
|
||||
gate.onAuthSuccess(p)
|
||||
assertTrue(gate.isAuthLatched(p), "only a re-add clears a latch, never a success")
|
||||
assertTrue(gate.isAuthBlocked(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-adding the account clears a latched circuit`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
repeat(yahoo.circuitOpenThreshold) { gate.onAuthFailure(p) }
|
||||
assertTrue(gate.isAuthLatched(p))
|
||||
|
||||
gate.onAccountReadded(p)
|
||||
|
||||
assertFalse(gate.isAuthLatched(p), "a re-add drops the latch")
|
||||
assertFalse(gate.isAuthBlocked(p), "and the account may attempt a fresh login again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onAuthSuccess clears the backoff and resets the failure count`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val first = gate.onAuthFailure(p)
|
||||
gate.onAuthFailure(p) // escalate to failure 2
|
||||
gate.onAuthSuccess(p)
|
||||
|
||||
assertFalse(gate.isAuthBlocked(p))
|
||||
// A fresh failure after recovery starts back at the base (failure 1) delay.
|
||||
assertEquals(first, gate.onAuthFailure(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blocked account never stalls another`() {
|
||||
val gate = gate()
|
||||
val blocked = params(user = "blocked@example.org")
|
||||
val healthy = params(user = "healthy@example.org")
|
||||
|
||||
gate.onAuthFailure(blocked)
|
||||
|
||||
assertTrue(gate.isAuthBlocked(blocked))
|
||||
assertFalse(gate.isAuthBlocked(healthy))
|
||||
assertEquals(0L, gate.remainingAuthBlockMillis(healthy))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an elapsed window stops blocking but still escalates a re-failure`() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val first = gate.onAuthFailure(p)
|
||||
now += first // the window elapses
|
||||
assertFalse(gate.isAuthBlocked(p), "the account may attempt a login once its window passes")
|
||||
|
||||
// Re-failing before any success keeps the failure count — it escalates, not restarts.
|
||||
val next = gate.onAuthFailure(p)
|
||||
assertTrue(next > first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the block window clears exactly when the backoff elapses`() = runTest {
|
||||
val gate = AuthThrottleGate(
|
||||
nowMillis = { testScheduler.currentTime },
|
||||
random = { 0.0 },
|
||||
policyForHost = { yahoo },
|
||||
)
|
||||
val p = params()
|
||||
|
||||
val backoff = gate.onAuthFailure(p)
|
||||
assertTrue(gate.isAuthBlocked(p))
|
||||
|
||||
advanceTimeBy(backoff - 1)
|
||||
assertTrue(gate.isAuthBlocked(p), "still blocked just before the window elapses")
|
||||
|
||||
advanceTimeBy(1)
|
||||
assertFalse(gate.isAuthBlocked(p), "cleared the instant the backoff elapses")
|
||||
assertEquals(0L, gate.remainingAuthBlockMillis(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-gated host is never blocked`() {
|
||||
// Gmail has no 1-hour auth lockout, so its real policy is DISABLED: failures record nothing.
|
||||
val gate = gate(policyForHost = ProviderAuthPolicy::forHost)
|
||||
val gmail = params(host = "imap.gmail.com")
|
||||
|
||||
assertEquals(0L, gate.onAuthFailure(gmail))
|
||||
assertFalse(gate.isAuthBlocked(gmail))
|
||||
assertTrue(logBuffer.snapshot().isEmpty(), "an ungated host logs no auth-backoff breadcrumb")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth backoff and recovery log PII-free breadcrumbs`() {
|
||||
val gate = gate()
|
||||
val p = params(user = "secret.user@example.org", host = "imap.mail.yahoo.com")
|
||||
|
||||
gate.onAuthFailure(p)
|
||||
gate.onAuthSuccess(p)
|
||||
|
||||
val messages = logBuffer.snapshot().map { it.message }
|
||||
assertTrue(messages.any { it.startsWith("auth backoff acct:") && it.contains("failures=1") })
|
||||
assertTrue(messages.any { it.startsWith("auth recovered acct:") })
|
||||
messages.forEach {
|
||||
assertFalse(it.contains("secret.user@example.org"), it)
|
||||
assertFalse(it.contains("yahoo"), it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onAuthSuccess on a healthy account is silent`() {
|
||||
gate().onAuthSuccess(params())
|
||||
|
||||
assertTrue(logBuffer.snapshot().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `composes with the reactive throttle gate without interference (issue #360)`() {
|
||||
val authGate = gate()
|
||||
val throttleGate = AccountThrottleGate(nowMillis = { now }, random = { 0.0 })
|
||||
val p = params()
|
||||
|
||||
// The same account can be BOTH proactively auth-backing-off and reactively throttled; the two
|
||||
// gates keep independent state and neither clears the other.
|
||||
val authBlock = authGate.onAuthFailure(p)
|
||||
val lockout = throttleGate.onThrottle("acct", ThrottleSignal(ThrottleKind.LOCKOUT))
|
||||
assertTrue(authGate.isAuthBlocked(p))
|
||||
assertTrue(throttleGate.isThrottled("acct"))
|
||||
authGate.onAuthSuccess(p)
|
||||
assertFalse(authGate.isAuthBlocked(p))
|
||||
assertTrue(throttleGate.isThrottled("acct"), "clearing the auth gate must not clear the reactive one")
|
||||
|
||||
// The proactive auth backoff is always shorter than the reactive lockout it prevents reaching.
|
||||
assertTrue(authBlock < lockout, "proactive auth backoff ($authBlock) must be shorter than lockout ($lockout)")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PORT = 993
|
||||
const val RAPID_FAILURES = 10
|
||||
|
||||
/** How many old open-circuit windows to fast-forward to prove a latch never self-clears. */
|
||||
const val LATCH_ELAPSE_FACTOR = 10
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import com.icegreen.greenmail.util.GreenMail
|
||||
import com.icegreen.greenmail.util.ServerSetupTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import java.net.ServerSocket
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The issue-#362 auth circuit-breaker's **enforcement** inside [ImapClient], against a real GreenMail
|
||||
* IMAP server. Proves the load-bearing distinctions end to end:
|
||||
* - a rejected `LOGIN` (auth failure) **arms** the proactive backoff;
|
||||
* - a transient connect error (connection refused) **does not** — "transient IMAP error ≠ auth backoff";
|
||||
* - while backing off, a subsequent login is **skipped** ([AuthBackoffException]) rather than attempted,
|
||||
* even with a correct credential, so no failed-login storm can reach the provider;
|
||||
* - a successful login leaves (or clears) the backoff.
|
||||
*
|
||||
* The gate is injected with an always-Yahoo policy and a manual clock, so a GreenMail server on
|
||||
* `127.0.0.1` is treated as a gated Yahoo/AOL account and the backoff window is controllable without
|
||||
* real sleeps. Both the connect-per-op and connection-reuse code paths funnel through the same guarded
|
||||
* `openConnectedStore`, so a reuse-on client is checked too.
|
||||
*/
|
||||
class ImapClientAuthBackoffTest {
|
||||
|
||||
private lateinit var greenMail: GreenMail
|
||||
private var now = 0L
|
||||
private val yahoo = ProviderAuthPolicy.forHost(MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host)
|
||||
private val gate = AuthThrottleGate(nowMillis = { now }, random = { 0.0 }, policyForHost = { yahoo })
|
||||
|
||||
private fun client(reuse: Boolean = false) = ImapClient(reuseConnections = reuse, authGate = gate)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
|
||||
greenMail.start()
|
||||
greenMail.setUser("alice@example.org", "secret")
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.d(any(), any()) } returns 0
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
greenMail.stop()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun params(secret: String = "secret", port: Int = greenMail.imap.port) = ImapConnectionParams(
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
security = MailSecurity.NONE,
|
||||
username = "alice@example.org",
|
||||
secret = secret,
|
||||
useXoauth2 = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a wrong-password auth failure arms the proactive backoff`() = runTest {
|
||||
assertFailsWith<Exception> { client().listFolders(params(secret = "wrong-password")) }
|
||||
|
||||
assertTrue(gate.isAuthBlocked(params()), "a rejected LOGIN must arm the auth circuit-breaker")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient connect error does not arm the backoff`() = runTest {
|
||||
val closedPort = ServerSocket(0).use { it.localPort } // now free → connection refused, not an auth NO
|
||||
|
||||
assertFailsWith<Exception> { client().listFolders(params(port = closedPort)) }
|
||||
|
||||
assertFalse(
|
||||
gate.isAuthBlocked(params(port = closedPort)),
|
||||
"a transient (non-auth) connect error must NOT arm the auth backoff",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful login leaves the backoff clear`() = runTest {
|
||||
client().listFolders(params())
|
||||
|
||||
assertFalse(gate.isAuthBlocked(params()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `while backing off, a login is skipped even with a correct credential`() = runTest {
|
||||
// Arm the backoff with one rejected login...
|
||||
assertFailsWith<Exception> { client().listFolders(params(secret = "wrong-password")) }
|
||||
assertTrue(gate.isAuthBlocked(params()))
|
||||
|
||||
// ...then further logins are SKIPPED (AuthBackoffException from the guard), not attempted — even a
|
||||
// correct credential and a different operation, so no failed-login storm reaches the provider.
|
||||
assertFailsWith<AuthBackoffException> { client().listFolders(params()) }
|
||||
assertFailsWith<AuthBackoffException> { client().fetchRecent(params(), "INBOX", 10) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reuse path is gated too`() = runTest {
|
||||
// The connection-reuse client establishes its warm socket via the same guarded openConnectedStore.
|
||||
assertFailsWith<Exception> { client(reuse = true).listFolders(params(secret = "wrong-password")) }
|
||||
assertTrue(gate.isAuthBlocked(params()))
|
||||
assertFailsWith<AuthBackoffException> { client(reuse = true).listFolders(params()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a login succeeds again once the backoff window elapses`() = runTest {
|
||||
assertFailsWith<Exception> { client().listFolders(params(secret = "wrong-password")) }
|
||||
now += gate.remainingAuthBlockMillis(params()) // fast-forward past the window
|
||||
|
||||
client().listFolders(params()) // a correct login now goes through and clears the state
|
||||
|
||||
assertFalse(gate.isAuthBlocked(params()))
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* [ProviderAuthPolicy] must gate **only** Yahoo/AOL — the providers with the ~1-hour auth lockout
|
||||
* (issue #362) — with the conservative, lockout-avoiding cadence, and leave every other host
|
||||
* ([AuthCadencePolicy.DISABLED]) so siblings #361/#363/#364 and manual servers are unaffected. It must
|
||||
* also expose the documented connection / folder-index ceilings, and its whole schedule must stay under
|
||||
* the ~1-hour lockout window it protects.
|
||||
*/
|
||||
class ProviderAuthPolicyTest {
|
||||
|
||||
/** The real IMAP host a provider's accounts use — the single source of truth, not a hard-coded string. */
|
||||
private fun hostOf(provider: MailProvider) = provider.createAccount("user@example.com").imap.host
|
||||
|
||||
@Test
|
||||
fun `yahoo and aol get the enabled lockout-avoiding policy`() {
|
||||
for (provider in listOf(MailProvider.YAHOO, MailProvider.AOL)) {
|
||||
val policy = ProviderAuthPolicy.forHost(hostOf(provider))
|
||||
assertTrue(policy.enabled, "${provider.displayName} must be gated against its 1-hour auth lockout")
|
||||
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_BASE_MS, policy.baseBackoffMillis)
|
||||
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_MAX_MS, policy.maxBackoffMillis)
|
||||
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD, policy.circuitOpenThreshold)
|
||||
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_MS, policy.circuitOpenMillis)
|
||||
assertEquals(ProviderAuthPolicy.YAHOO_MAX_CONCURRENT_CONNECTIONS, policy.maxConcurrentConnections)
|
||||
assertEquals(ProviderAuthPolicy.YAHOO_FOLDER_INDEX_CAP, policy.folderIndexCap)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gmail and icloud are not gated`() {
|
||||
for (provider in listOf(MailProvider.GMAIL, MailProvider.ICLOUD)) {
|
||||
assertFalse(
|
||||
ProviderAuthPolicy.forHost(hostOf(provider)).enabled,
|
||||
"${provider.displayName} has no 1-hour auth lockout; issue #362 must leave it ungated",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outlook, unknown, and blank hosts are not gated`() {
|
||||
assertFalse(ProviderAuthPolicy.forHost("outlook.office365.com").enabled)
|
||||
assertFalse(ProviderAuthPolicy.forHost("imap.example.com").enabled)
|
||||
assertFalse(ProviderAuthPolicy.forHost("").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the disabled policy can never block a login`() {
|
||||
// A disabled policy's threshold is unreachable and its windows are zero, so a non-Yahoo/AOL
|
||||
// account is provably never gated regardless of how [AuthBackoff] is called.
|
||||
assertFalse(AuthCadencePolicy.DISABLED.enabled)
|
||||
assertEquals(Int.MAX_VALUE, AuthCadencePolicy.DISABLED.circuitOpenThreshold)
|
||||
assertEquals(0L, AuthBackoff.blockMillis(AuthCadencePolicy.DISABLED, consecutiveFailures = 1, random = 1.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole yahoo schedule stays under the one-hour lockout window`() {
|
||||
// Recovery must beat the lockout: every wait the policy can impose is under an hour, so a
|
||||
// recovered account resumes far sooner than a self-inflicted hour of silence.
|
||||
assertTrue(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_BASE_MS < ONE_HOUR_MS)
|
||||
assertTrue(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_MAX_MS < ONE_HOUR_MS)
|
||||
assertTrue(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_MS < ONE_HOUR_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the documented connection and folder-index ceilings are exposed`() {
|
||||
// Reuse (issues #125/#357, ON by default) keeps an account to ~1 warm IMAP socket + at most one
|
||||
// IDLE connection = 2, comfortably under this documented ceiling of 5.
|
||||
assertEquals(5, ProviderAuthPolicy.YAHOO_MAX_CONCURRENT_CONNECTIONS)
|
||||
assertEquals(10_000, ProviderAuthPolicy.YAHOO_FOLDER_INDEX_CAP)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ONE_HOUR_MS = 60 * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.lock
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.work.Operation
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.libremail.data.security.AppLockGate
|
||||
import org.libremail.data.security.AppLockManager
|
||||
import org.libremail.data.security.DatabaseKeyCipher
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.LockState
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import org.libremail.restart.ProcessRestarter
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The issue-#479 seal-present/setting-off matrix: `onForeground` must derive
|
||||
* [org.libremail.data.security.KeyInvalidationPolicy]'s `encryptedCacheProtected` input from the
|
||||
* ACTUAL protection state (`encryptCache setting || hasAuthSealedPassphrase()`), never the setting
|
||||
* alone. The setting flips immediately, but the on-disk decrypt runs only at the next cold start, so
|
||||
* an auth-sealed (still encrypted) cache can outlive `encryptCache == false` — the transitional
|
||||
* window in which the old raw-setting input silently chose DISABLE_APP_LOCK on device-lock removal,
|
||||
* orphaned SEALED_AUTH without a wipe, and bricked every later launch inside
|
||||
* `DatabaseKeyStore.resolvePassphrase` (a `session.await()` nothing could ever complete).
|
||||
*
|
||||
* Unlike `AppLockViewModelTest`'s LockAction-dispatch tests (which stub the policy object), these
|
||||
* drive the REAL [org.libremail.data.security.KeyInvalidationPolicy] through the ViewModel so the
|
||||
* derivation itself — not just the dispatch — is pinned. Split into its own class (rather than grown
|
||||
* onto AppLockViewModelTest) to respect detekt's LargeClass budget there.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppLockViewModelSealStateTest {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
private val logBuffer = RingLogBuffer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
// android.util.Log is a throwing stub under plain JVM tests and AppLog always forwards to
|
||||
// it; stub the levels these paths breadcrumb through, then install a real buffer so the
|
||||
// breadcrumbs are asserted for real (never `verify { Log... }`) — mirroring AppLockViewModelTest.
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||
AppLog.install(logBuffer)
|
||||
mockkStatic(SystemClock::class)
|
||||
every { SystemClock.elapsedRealtime() } returns FOREGROUND_AT
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removal during the encryptCache-off transitional window clears and disables`() = runTest(dispatcher) {
|
||||
val fixture = fixture(deviceSecure = false, authSealed = true)
|
||||
|
||||
fixture.vm.onForeground()
|
||||
advanceUntilIdle()
|
||||
|
||||
// The setting is off but SEALED_AUTH still exists, so the policy must land on
|
||||
// CLEAR_AND_DISABLE: wipe scheduled, gate dropped, process restarted — never the old
|
||||
// DISABLE_APP_LOCK, which orphaned the seal without a wipe and hung every later launch
|
||||
// forever inside DatabaseKeyStore.resolvePassphrase (the app bricked behind the cache gate).
|
||||
coVerifyOrder {
|
||||
fixture.databaseKeyStore.setClearPending()
|
||||
fixture.settingsRepository.setAppLock(false)
|
||||
fixture.processRestarter.restart()
|
||||
}
|
||||
// Issue #479: the seal-overrides-setting derivation and the decision are both breadcrumbed.
|
||||
val messages = logBuffer.snapshot().map { it.message }
|
||||
assertTrue("encryptCache off but passphrase still auth-sealed; treating cache as protected" in messages)
|
||||
assertTrue("app-lock foreground decision: CLEAR_AND_DISABLE" in messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock removal with no auth seal and the setting off disables without clearing`() = runTest(dispatcher) {
|
||||
val fixture = fixture(deviceSecure = false, authSealed = false)
|
||||
|
||||
fixture.vm.onForeground()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Nothing encrypted and nothing sealed: still DISABLE_APP_LOCK — the seal-derived input
|
||||
// must not turn every lock removal into a destructive wipe.
|
||||
coVerify { fixture.settingsRepository.setAppLock(false) }
|
||||
coVerify(exactly = 0) { fixture.databaseKeyStore.setClearPending() }
|
||||
verify(exactly = 0) { fixture.processRestarter.restart() }
|
||||
assertIs<AppLockUiState.Unlocked>(fixture.vm.uiState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key invalidation while still auth-sealed with the setting off clears and keeps the lock`() =
|
||||
runTest(dispatcher) {
|
||||
val fixture = fixture(deviceSecure = true, authSealed = true, keyInvalidated = true)
|
||||
|
||||
fixture.vm.onForeground()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Biometric re-enrollment during the transitional window: the auth-sealed cache is
|
||||
// unrecoverable, so it is cleared and app-lock stays ON (CLEAR_AND_REQUIRE_AUTH).
|
||||
coVerify { fixture.databaseKeyStore.setClearPending() }
|
||||
verify { fixture.processRestarter.restart() }
|
||||
coVerify(exactly = 0) { fixture.settingsRepository.setAppLock(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a healthy device still just requires auth during the transitional window`() = runTest(dispatcher) {
|
||||
val gate = mockk<AppLockGate>(relaxed = true)
|
||||
every { gate.state } returns LockState.LOCKED
|
||||
val fixture = fixture(deviceSecure = true, authSealed = true, gate = gate)
|
||||
|
||||
fixture.vm.onForeground()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Secure device + valid key: the seal-derived input must not disturb the healthy row —
|
||||
// REQUIRE_AUTH, no wipe, no restart (the seal is unwrapped after the next auth as before).
|
||||
verify { gate.onForeground(FOREGROUND_AT, appLockEnabled = true) }
|
||||
assertIs<AppLockUiState.Locked>(fixture.vm.uiState.value)
|
||||
coVerify(exactly = 0) { fixture.databaseKeyStore.setClearPending() }
|
||||
verify(exactly = 0) { fixture.processRestarter.restart() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A ViewModel over the REAL KeyInvalidationPolicy with app-lock ON and encryptCache OFF (the
|
||||
* transitional window's setting state), plus mocks pinned to the given device/seal/key state.
|
||||
*/
|
||||
private class Fixture(
|
||||
val vm: AppLockViewModel,
|
||||
val settingsRepository: SettingsRepository,
|
||||
val databaseKeyStore: DatabaseKeyStore,
|
||||
val processRestarter: ProcessRestarter,
|
||||
)
|
||||
|
||||
private fun fixture(
|
||||
deviceSecure: Boolean,
|
||||
authSealed: Boolean,
|
||||
keyInvalidated: Boolean = false,
|
||||
gate: AppLockGate = mockk(relaxed = true),
|
||||
): Fixture {
|
||||
val settingsRepository = mockk<SettingsRepository>(relaxed = true)
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = true, encryptCache = false))
|
||||
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
|
||||
coEvery { databaseKeyStore.hasAuthSealedPassphrase() } returns authSealed
|
||||
val appLockManager = mockk<AppLockManager>(relaxed = true)
|
||||
every { appLockManager.isDeviceSecure() } returns deviceSecure
|
||||
val databaseKeyCipher = mockk<DatabaseKeyCipher>(relaxed = true)
|
||||
every { databaseKeyCipher.isInvalidated() } returns keyInvalidated
|
||||
val processRestarter = mockk<ProcessRestarter>(relaxed = true)
|
||||
val vm = AppLockViewModel(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
settingsRepository = settingsRepository,
|
||||
appLockManager = appLockManager,
|
||||
databaseKeyStore = databaseKeyStore,
|
||||
databaseKeyCipher = databaseKeyCipher,
|
||||
session = mockk<PassphraseSession>(relaxed = true),
|
||||
syncScheduler = enqueueingScheduler(),
|
||||
processRestarter = processRestarter,
|
||||
gate = gate,
|
||||
).also { it.defaultDispatcher = dispatcher }
|
||||
return Fixture(vm, settingsRepository, databaseKeyStore, processRestarter)
|
||||
}
|
||||
|
||||
/** A [SyncScheduler] whose `syncNow()` returns an [Operation] whose result future resolves. */
|
||||
private fun enqueueingScheduler(): SyncScheduler {
|
||||
val future = mockk<ListenableFuture<Operation.State.SUCCESS>>()
|
||||
every { future.get(any(), any()) } returns Operation.SUCCESS
|
||||
val operation = mockk<Operation> { every { result } returns future }
|
||||
return mockk<SyncScheduler> { every { syncNow() } returns operation }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FOREGROUND_AT = 1_000L
|
||||
}
|
||||
}
|
||||
@@ -165,21 +165,6 @@ class MailboxScreenJvmTest {
|
||||
assertTrue(composeTestRule.onAllNodesWithText("b@example.org").fetchSemanticsNodes().isNotEmpty())
|
||||
}
|
||||
|
||||
// #362: an account whose auth circuit has latched carries a persisted authError; the mailbox shows a
|
||||
// persistent (non-dismissable) banner with the "remove and re-add" message while that state holds.
|
||||
@Test
|
||||
fun erroredAccount_showsPersistentAuthErrorBanner() {
|
||||
val errored = account.copy(
|
||||
authError = string(R.string.account_auth_error_remove_readd),
|
||||
)
|
||||
setContent(accounts = listOf(errored), messages = listOf(message("1", subject = "Msg")))
|
||||
waitForText("Msg")
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(string(R.string.account_auth_error_remove_readd))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draftsAndOutboxEntries_showAtInbox_andNavigate() {
|
||||
var openedDrafts = false
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
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 org.libremail.ui.theme.LibreMailTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Renders the Settings account list ([AccountReorderList]) on the JVM under Robolectric to prove the
|
||||
* per-account auth-error indicator (issue #362): an errored account row shows the "remove and re-add"
|
||||
* message beneath its address, while a healthy account shows none.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36])
|
||||
class AccountReorderListRenderTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val message: String
|
||||
get() = RuntimeEnvironment.getApplication().getString(R.string.account_auth_error_remove_readd)
|
||||
|
||||
private fun account(authError: String?) = 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),
|
||||
authError = authError,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun erroredAccountRow_showsTheAuthErrorMessage() {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme {
|
||||
AccountReorderList(accounts = listOf(account(message)), onOpenAccount = {}, onReorder = {})
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("a@example.org").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(message).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun healthyAccountRow_showsNoAuthError() {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme {
|
||||
AccountReorderList(accounts = listOf(account(null)), onOpenAccount = {}, onReorder = {})
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
composeTestRule.onAllNodesWithText(message).fetchSemanticsNodes().isEmpty(),
|
||||
"a healthy account row shows no auth-error message",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,8 @@ complexity:
|
||||
#
|
||||
# MailBackfillerTest is the same pattern: one cohesive single-SUT suite (a test per backfill concern
|
||||
# — #12 core paging, #94/#95 boundary edge cases, #322 batching, #360 throttle, #355 interactive
|
||||
# priority, #329 logging) already at the boundary; the iCloud connection-cap wiring tests (#363) and
|
||||
# issue #362's fail-loud auth-error errored/latched-skip tests each tipped it further over.
|
||||
# priority, #329 logging) already at the boundary; the iCloud connection-cap wiring tests (issue
|
||||
# #363) tipped it over.
|
||||
excludes: ['**/data/repository/MailRepositoryImplTest.kt', '**/data/sync/MailBackfillerTest.kt']
|
||||
|
||||
naming:
|
||||
|
||||
Reference in New Issue
Block a user