fix(security): move accounts/credentials to a non-auth-bound database
Accounts, credentials, per-account settings and signatures lived in the same libremail.db that SQLCipher encrypts under the auth-bound passphrase when app-lock + encrypted-cache are on. A genuine key invalidation (biometric re-enrollment or lock removal/re-add) made that file undecryptable, and the "clear + re-sync" recovery wiped the accounts and stored credentials along with the mail cache, dropping the user into onboarding (issue #111). Move those four tables into a new plaintext AccountDatabase (libremail-accounts.db) that is never bound to the auth key. Credentials stay AES-GCM sealed at the column level by the surviving non-auth KeystoreCrypto master key, so the only secret never touches disk in the clear. A cache-key invalidation now wipes only libremail.db; the user stays signed in. - AccountDatabase (v1) + AccountDatabaseModule; the cache DB drops to v15 via MIGRATION_14_15. DAOs are unchanged and re-provided from the new DB, so no injection site changes. - AccountDataMigrator performs the one-time cross-DB copy at startup, before Room opens either database. It attaches the cache (with its resolved passphrase, so an encrypted source is handled) and copies with INSERT OR IGNORE. It is crash-safe and idempotent: the source is dropped only by MIGRATION_14_15 after the copy, a re-run never duplicates or overwrites, and it runs after the clear-pending wipe so an unrecoverable cache degrades to "nothing to move" instead of blocking. - Exported schemas for both databases; MigrationTest asserts the account rows/backfills survive to v14 then are dropped at v15, plus a dedicated 14->15 test. AccountDataMigratorTest covers the plaintext + encrypted copy, idempotency, a DDL-vs-Room drift guard, and end-to-end survival of a simulated cache wipe. Closes #111 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
|
||||
/**
|
||||
* The one-time move performed by [AccountDataMigrator] (issue #111): copying accounts / credentials /
|
||||
* per-account settings / signatures out of the cache database into the plaintext [AccountDatabase].
|
||||
*
|
||||
* Exercises the [AccountDataMigrator.copyAccountTables] core directly (the full [AccountDataMigrator]
|
||||
* also resolves the passphrase and flips the done-flag, which need the real DataStore/Keystore). A v14
|
||||
* cache is built with [MigrationTestHelper] from the exported schema, so the copy runs against exactly
|
||||
* the on-disk shape an upgrading user has.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDataMigratorTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
LibreMailDatabase::class.java,
|
||||
emptyList(),
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val cacheName = "acct-migrator-cache-test.db"
|
||||
private val accountsName = "acct-migrator-accounts-test.db"
|
||||
private val cacheFile get() = context.getDatabasePath(cacheName)
|
||||
private val accountsFile get() = context.getDatabasePath(accountsName)
|
||||
|
||||
// 64 hex chars == a 32-byte SQLCipher passphrase.
|
||||
private val passphrase = "0123456789abcdef".repeat(4)
|
||||
|
||||
@Before
|
||||
@After
|
||||
fun clean() {
|
||||
listOf(cacheName, accountsName).forEach { name ->
|
||||
context.deleteDatabase(name)
|
||||
context.getDatabasePath(name).parentFile
|
||||
?.listFiles { f -> f.name.startsWith(name) }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a v14 cache holding one fully-populated account plus a mail row. */
|
||||
private fun seedVersion14Cache() {
|
||||
helper.createDatabase(cacheName, 14).apply {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
|
||||
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
|
||||
execSQL(
|
||||
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled, " +
|
||||
"retentionCount, retentionMonths) VALUES ('acct', 'Cheers', 1, 0, NULL, 6)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO signatures (id, accountId, name, contentHtml, isDefault) " +
|
||||
"VALUES ('sig-1', 'acct', 'Work', '<p>Regards</p>', 1)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada', 'a@x', 'Hi', '', '', 0, 1, 0, 0, 'INBOX', 1, 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAccountsDb(): AccountDatabase =
|
||||
Room.databaseBuilder(context, AccountDatabase::class.java, accountsName).build()
|
||||
|
||||
@Test
|
||||
fun movesEveryAccountTableOutOfAPlaintextCache() = runBlocking {
|
||||
seedVersion14Cache()
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// First open lets Room stamp its identity onto the migrator-created file; reopen so a real
|
||||
// session's reads run against a fully Room-owned database.
|
||||
openAccountsDb().apply {
|
||||
assertEquals("Ada", accountDao().getById("acct")?.displayName)
|
||||
close()
|
||||
}
|
||||
openAccountsDb().apply {
|
||||
val account = accountDao().getById("acct")
|
||||
assertEquals("ada@example.org", account?.email)
|
||||
assertEquals(993, account?.imap?.port)
|
||||
assertEquals("smtp.example.org", account?.smtp?.host)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
val settings = accountSettingsDao().get("acct")
|
||||
assertEquals(false, settings?.signatureEnabled)
|
||||
assertEquals(6, settings?.retentionMonths)
|
||||
assertNull(settings?.retentionCount)
|
||||
val signatures = signatureDao().observeForAccount("acct").first()
|
||||
assertEquals(listOf("Work"), signatures.map { it.name })
|
||||
assertTrue("the default flag must round-trip", signatures.single().isDefault)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movesAccountsOutOfAnEncryptedCache() = runBlocking {
|
||||
seedVersion14Cache()
|
||||
// Turn the cache into the SQLCipher form an app-lock + encrypted-cache user has on disk.
|
||||
DatabaseEncryption.ensureEncrypted(cacheFile, passphrase)
|
||||
assertTrue("precondition: the source cache is encrypted", DatabaseEncryption.isEncrypted(cacheFile))
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = passphrase, accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertNotNull(accountDao().getById("acct"))
|
||||
close()
|
||||
}
|
||||
openAccountsDb().apply {
|
||||
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking {
|
||||
seedVersion14Cache()
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// Simulate the user editing an account AFTER the migration.
|
||||
openAccountsDb().apply {
|
||||
val edited = accountDao().getById("acct")!!.copy(displayName = "Ada Lovelace")
|
||||
accountDao().upsert(edited)
|
||||
close()
|
||||
}
|
||||
|
||||
// A re-run (e.g. after a mid-startup crash before the done-flag was set) must not clobber it.
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertEquals(1, accountDao().getAll().size)
|
||||
assertEquals(
|
||||
"INSERT OR IGNORE must not overwrite the post-migration edit",
|
||||
"Ada Lovelace",
|
||||
accountDao().getById("acct")?.displayName,
|
||||
)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsAndCredentialsSurviveACacheWipe() = runBlocking {
|
||||
seedVersion14Cache()
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// The "clear + re-sync" recovery wipes only the cache file; AccountDatabase is a separate file.
|
||||
context.deleteDatabase(cacheName)
|
||||
assertTrue("precondition: the cache file is gone", !cacheFile.exists())
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertNotNull("the account must outlive a cache wipe (issue #111)", accountDao().getById("acct"))
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
// And it is still usable: a fresh credential can be written with no cache present.
|
||||
credentialDao().upsert(CredentialEntity("acct", "rotated"))
|
||||
assertEquals("rotated", credentialDao().getById("acct")?.encryptedSecret)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
|
||||
val schema = JSONObject(
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("org.libremail.data.local.AccountDatabase/1.json")
|
||||
.bufferedReader().use { it.readText() },
|
||||
).getJSONObject("database")
|
||||
val entities = schema.getJSONArray("entities")
|
||||
|
||||
var checkedIndex = false
|
||||
for (i in 0 until entities.length()) {
|
||||
val entity = entities.getJSONObject(i)
|
||||
val table = entity.getString("tableName")
|
||||
val expectedCreate = entity.getString("createSql").replace("\${TABLE_NAME}", "`$table`")
|
||||
assertEquals(
|
||||
"AccountDataMigrator DDL for `$table` must match the exported AccountDatabase schema",
|
||||
expectedCreate,
|
||||
AccountDataMigrator.CREATE_TABLE_SQL[table],
|
||||
)
|
||||
if (entity.has("indices")) {
|
||||
val indices = entity.getJSONArray("indices")
|
||||
for (j in 0 until indices.length()) {
|
||||
val index = indices.getJSONObject(j)
|
||||
if (index.getString("name") == "index_signatures_accountId") {
|
||||
assertEquals(
|
||||
"AccountDataMigrator signatures index must match the exported schema",
|
||||
index.getString("createSql").replace("\${TABLE_NAME}", "`$table`"),
|
||||
AccountDataMigrator.SIGNATURES_INDEX_SQL,
|
||||
)
|
||||
checkedIndex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
"every migrator table DDL must correspond to an exported entity",
|
||||
AccountDataMigrator.CREATE_TABLE_SQL.keys,
|
||||
(0 until entities.length()).map { entities.getJSONObject(it).getString("tableName") }.toSet(),
|
||||
)
|
||||
assertTrue("the signatures index must be present in the exported schema", checkedIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
/**
|
||||
* Behavior of the non-auth [AccountDatabase] that holds accounts, credentials, per-account settings
|
||||
* and signatures after they were moved out of the cache database (issue #111). Confirms the tables'
|
||||
* foreign keys still cascade from the account, now that they live together in this database.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDatabaseTest {
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun account(id: String = "acct") = AccountEntity(
|
||||
id = id,
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun credentialRoundTripsAndIsIndependentOfTheAccountRow() = runBlocking {
|
||||
db.accountDao().upsert(account())
|
||||
db.credentialDao().upsert(CredentialEntity("acct", "sealed-secret"))
|
||||
|
||||
assertEquals("sealed-secret", db.credentialDao().getById("acct")?.encryptedSecret)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountSettingsRoundTripAndCascadeWithTheirAccount() = runBlocking {
|
||||
db.accountDao().upsert(account())
|
||||
db.accountSettingsDao().upsert(
|
||||
AccountSettingsEntity("acct", signature = "Hi", signatureEnabled = false, notificationsEnabled = false),
|
||||
)
|
||||
assertEquals("Hi", db.accountSettingsDao().get("acct")?.signature)
|
||||
|
||||
db.accountDao().deleteById("acct")
|
||||
|
||||
assertNull("account_settings must cascade-delete with its account", db.accountSettingsDao().get("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signaturesCascadeWithTheirAccount() = runBlocking {
|
||||
db.accountDao().upsert(account())
|
||||
db.signatureDao().upsert(SignatureEntity("sig-1", "acct", "Work", "<p>Regards</p>", isDefault = true))
|
||||
assertEquals(1, db.signatureDao().observeForAccount("acct").first().size)
|
||||
|
||||
db.accountDao().deleteById("acct")
|
||||
|
||||
assertTrue(
|
||||
"signatures must cascade-delete with their account",
|
||||
db.signatureDao().observeForAccount("acct").first().isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,21 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
|
||||
/**
|
||||
* Schema-behavior tests on a fresh in-memory database at the current version. The migration DDL
|
||||
* itself is exercised by [MigrationTest], which replays the schema chain exported to app/schemas.
|
||||
* (Migrations from before v7 predate schema export, so they can't be replayed there.)
|
||||
*
|
||||
* Account/credential/settings/signature behavior moved to [AccountDatabaseTest] with those tables
|
||||
* (issue #111).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibreMailDatabaseTest {
|
||||
@@ -97,30 +96,6 @@ class LibreMailDatabaseTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountSettingsRoundTripAndCascadeWithTheirAccount() = runBlocking {
|
||||
val accountDao = db.accountDao()
|
||||
val settingsDao = db.accountSettingsDao()
|
||||
accountDao.upsert(
|
||||
AccountEntity(
|
||||
id = "acct",
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
),
|
||||
)
|
||||
settingsDao.upsert(
|
||||
AccountSettingsEntity("acct", signature = "Hi", signatureEnabled = false, notificationsEnabled = false),
|
||||
)
|
||||
assertEquals("Hi", settingsDao.get("acct")?.signature)
|
||||
|
||||
accountDao.deleteById("acct")
|
||||
|
||||
assertNull("account_settings must cascade-delete with its account", settingsDao.get("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchRowsAreNotInboxAndAreCleared() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
|
||||
@@ -133,6 +133,9 @@ class MigrationTest {
|
||||
open?.close()
|
||||
val stepDb = helper.runMigrationsAndValidate(TEST_DB, migration.endVersion, true, migration)
|
||||
stepDb.writeMidChainData()
|
||||
// v16 moves the account tables out to AccountDatabase and drops them, so assert their rows
|
||||
// and backfills reached v15 intact — just before the move (issue #111).
|
||||
if (stepDb.version == 15) stepDb.assertAccountDataPresentAtV15()
|
||||
open = stepDb
|
||||
}
|
||||
val db = checkNotNull(open) { "no migration starts at v$OLDEST_EXPORTED_SCHEMA" }
|
||||
@@ -140,6 +143,29 @@ class MigrationTest {
|
||||
assertEquals("the chain must end at the newest exported schema", latestExportedSchemaVersion(), db.version)
|
||||
db.assertVersion7CacheSurvived()
|
||||
db.assertMigrationBackfillsApplied()
|
||||
db.assertAccountTablesDroppedAtV16()
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v15 -> v16 (issue #111): the moved account tables are dropped and the mail cache is untouched. */
|
||||
@Test
|
||||
fun migrate15To16_dropsMovedAccountTablesAndKeepsCache() {
|
||||
helper.createDatabase(TEST_DB, 15).apply {
|
||||
insertAccount()
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed')")
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
|
||||
"'INBOX', 1, 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 16, true, MIGRATION_15_16)
|
||||
|
||||
db.assertAccountTablesDroppedAtV16()
|
||||
assertEquals("the mail cache must be untouched by 15->16", 1, db.count("messages"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
@@ -203,9 +229,8 @@ class MigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every row cached at v7 must still be present and correct at the end of the chain. */
|
||||
/** Every mail-cache row cached at v7 must survive to v16 (account tables are checked separately). */
|
||||
private fun SupportSQLiteDatabase.assertVersion7CacheSurvived() {
|
||||
assertEquals(1, count("accounts"))
|
||||
assertEquals(2, count("messages"))
|
||||
assertEquals(1, count("outbox"))
|
||||
assertEquals(1, count("drafts"))
|
||||
@@ -215,24 +240,14 @@ class MigrationTest {
|
||||
assertEquals("Analytical engines", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
}
|
||||
query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("stored credentials must never be dropped by a migration", c.moveToFirst())
|
||||
assertEquals("sealed-secret", c.getString(0))
|
||||
}
|
||||
query("SELECT filename FROM attachments WHERE messageId = 'acct:1'").use { c ->
|
||||
assertTrue("attachment rows must survive the 6->7 style table rebuilds", c.moveToFirst())
|
||||
assertEquals("notes.pdf", c.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
/** Columns and rows created by the migrations themselves must hold their documented defaults. */
|
||||
/** Cache-table columns/rows the migrations backfill must hold their documented defaults at v16. */
|
||||
private fun SupportSQLiteDatabase.assertMigrationBackfillsApplied() {
|
||||
// 8->9 backfills one default settings row per existing account.
|
||||
query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c ->
|
||||
assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst())
|
||||
assertEquals(1, c.getInt(0))
|
||||
assertEquals(1, c.getInt(1))
|
||||
}
|
||||
// 9->10 adds bcc columns defaulting to ''; 10->11 adds nullable bodyHtml.
|
||||
query("SELECT bccAddresses, bodyHtml FROM outbox WHERE id = 'out-1'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
@@ -244,14 +259,6 @@ class MigrationTest {
|
||||
assertEquals("", c.getString(0))
|
||||
assertTrue(c.isNull(1))
|
||||
}
|
||||
// 10->11 turns the signature written at v9 into that account's default rich-text signature.
|
||||
query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst())
|
||||
assertEquals("Signature", c.getString(0))
|
||||
assertEquals("Cheers,<br>Ada", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
assertFalse("exactly one signature row must be backfilled", c.moveToNext())
|
||||
}
|
||||
// 11->12 stamps the folder cached at v8 as not special-use.
|
||||
query("SELECT specialUse FROM folders WHERE fullName = 'INBOX'").use { c ->
|
||||
assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst())
|
||||
@@ -265,6 +272,42 @@ class MigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account tables' rows + migration backfills must be intact at v15, just before 15->16 moves
|
||||
* them to [AccountDatabase] and drops them (issue #111). AccountDataMigrator's own copy is
|
||||
* exercised in `AccountDataMigratorTest`; here we only assert the source rows reach the move point.
|
||||
*/
|
||||
private fun SupportSQLiteDatabase.assertAccountDataPresentAtV15() {
|
||||
assertEquals(1, count("accounts"))
|
||||
query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("stored credentials must reach v15 before the move", c.moveToFirst())
|
||||
assertEquals("sealed-secret", c.getString(0))
|
||||
}
|
||||
// 8->9 backfills one default settings row per existing account.
|
||||
query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c ->
|
||||
assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst())
|
||||
assertEquals(1, c.getInt(0))
|
||||
assertEquals(1, c.getInt(1))
|
||||
}
|
||||
// 10->11 turns the signature written at v9 into that account's default rich-text signature.
|
||||
query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst())
|
||||
assertEquals("Signature", c.getString(0))
|
||||
assertEquals("Cheers,<br>Ada", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
assertFalse("exactly one signature row must be backfilled", c.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
/** 15->16 drops the account tables from the cache (AccountDataMigrator copies them out first). */
|
||||
private fun SupportSQLiteDatabase.assertAccountTablesDroppedAtV16() {
|
||||
listOf("accounts", "credentials", "account_settings", "signatures").forEach { table ->
|
||||
query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '$table'").use { c ->
|
||||
assertFalse("15->16 must drop `$table` from the cache database", c.moveToFirst())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c ->
|
||||
c.moveToFirst()
|
||||
c.getInt(0)
|
||||
|
||||
@@ -26,7 +26,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.contacts.ContactsRepository
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
@@ -56,7 +56,7 @@ class ComposeScreenTest {
|
||||
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private var db: LibreMailDatabase? = null
|
||||
private var db: AccountDatabase? = null
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
@@ -79,7 +79,7 @@ class ComposeScreenTest {
|
||||
// Build the view model once and capture it, so recomposition doesn't recreate it.
|
||||
private fun setContent(mailRepository: FakeMailRepository = FakeMailRepository(), onBack: () -> Unit = {}) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val database = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build().also { db = it }
|
||||
val database = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build().also { db = it }
|
||||
val viewModel = ComposeViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
mailRepository = mailRepository,
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
@@ -59,7 +59,7 @@ class AccountSettingsScreenTest {
|
||||
// stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out
|
||||
// from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with
|
||||
// the test process.
|
||||
val db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
val db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
val repository = AccountSettingsRepository(db.accountSettingsDao())
|
||||
runBlocking {
|
||||
db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row
|
||||
|
||||
Reference in New Issue
Block a user