test(db): pin native-lib-before-keyed-open at the DatabaseModule factory site #278

Merged
JMR-dev merged 4 commits from test-220-native-lib-before-keyed-open into main 2026-07-04 03:52:46 +00:00
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.di
import android.content.Context
import android.content.ContextWrapper
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.AccountDataMigrator
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import java.io.File
/**
* Pins the "native lib loaded before keyed open" invariant (592a797) at the OPEN site — issue #220's
* follow-up to #208/#210. `DatabaseProvisionerTest` (JVM, mocked) and `DatabaseProvisionerInstrumentedTest`
* (instrumented, real SQLCipher) both pin that [DatabaseProvisioner.prepareCache] calls
* `DatabaseEncryption.ensureNativeLibraryLoaded()` for the encrypted branch, but neither exercises
* [DatabaseModule.provideDatabase] itself: the instrumented one opens through a hand-rolled
* `SupportOpenHelperFactory`, bypassing the branch that actually picks between it and
* `FrameworkSQLiteOpenHelperFactory` based on what [DatabaseProvisioner] reports. A regression that
* breaks THAT wiring — swaps the branches, or stops gating the open on
* [DatabaseProvisioner.prepareCache] at all — would slip through both existing guards.
*
* These tests call [DatabaseModule.provideDatabase] directly (a plain function on the `object`, no Hilt
* graph needed) and drive the first real open through its own
* [org.libremail.data.local.DeferredOpenHelperFactory] lambda, mirroring the lane-3 instrumented style:
* MockK-mocked collaborators, a real [ContextWrapper] (never `mockk<Context>()`, which trips an ART
* parameter-annotation mismatch on API 31/32), and real SQLCipher for the encrypted path.
*/
@RunWith(AndroidJUnit4::class)
class DatabaseModuleInstrumentedTest {
private val appContext = ApplicationProvider.getApplicationContext<Context>()
private val dbName = "database_module_instrumented_test.db"
private val dbFile: File get() = appContext.getDatabasePath(dbName)
// 64 hex chars == a 32-byte SQLCipher passphrase, matching DatabaseKeyStore's format.
private val passphrase = "0123456789abcdef".repeat(4)
private val keyStore = mockk<DatabaseKeyStore>()
private val settingsRepository = mockk<SettingsRepository>()
private val migrator = mockk<AccountDataMigrator>()
// provideDatabase hardcodes DatabaseFiles.NAME as the Room db name, exactly like DatabaseProvisioner
// does internally when it resolves the cache file — so redirecting getDatabasePath for that one name
// routes BOTH the provisioner's file checks and Room's own open through this test's private file,
// never the app's real cache.
private val context: Context = object : ContextWrapper(appContext) {
override fun getDatabasePath(name: String): File =
if (name == DatabaseFiles.NAME) dbFile else super.getDatabasePath(name)
}
@Before
fun setUp() {
clean()
coEvery { keyStore.isClearPending() } returns false
coEvery { keyStore.resolvePassphrase(any()) } returns passphrase
coEvery { migrator.migrateIfNeeded() } just Runs
}
@After
fun tearDown() {
unmockkAll()
clean()
}
private fun clean() {
appContext.deleteDatabase(dbName)
dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() }
}
private fun provisioner() = DatabaseProvisioner(context, keyStore, settingsRepository, migrator, Dispatchers.IO)
/**
* Builds a genuinely plaintext cache with one row, then converts it to real SQLCipher ciphertext —
* the steady-state shape an already-encrypted install has at the next cold start, where
* `ensureEncrypted` has nothing left to convert (592a797's exact failure mode: the load can't ride
* on a conversion that no-ops).
*/
private fun seedEncryptedCacheWithOneRow() {
Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build().apply {
runBlocking { messageDao().insertNew(listOf(message("acct:1"))) }
close()
}
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
}
private fun message(id: String) = MessageEntity(
id = id,
accountId = "acct",
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "",
body = "",
timestampMillis = 1_000L,
isRead = false,
isStarred = false,
)
@Test
fun encryptedBranchLoadsNativeLibraryBeforeTheKeyedOpenSucceeds() = runBlocking<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
seedEncryptedCacheWithOneRow()
assertTrue("precondition: the cache is already encrypted", DatabaseEncryption.isEncrypted(dbFile))
mockkObject(DatabaseEncryption) // spy: real implementations still run
val database = DatabaseModule.provideDatabase(context, provisioner())
try {
// The first real open, driven by provideDatabase's own DeferredOpenHelperFactory lambda and
// CacheOpenMode branch — NOT a hand-rolled SupportOpenHelperFactory bypass. If that branch
// ever opened this genuinely-encrypted file with the plaintext framework helper instead of
// SQLCipher's, this would throw (a plaintext driver can't parse SQLCipher ciphertext) rather
// than return the seeded row.
assertEquals(listOf("acct:1"), database.messageDao().observeSummaries().first().map { it.id })
} finally {
database.close()
}
// Regression guard (592a797), pinned at the open site: a steady-state encrypted start converts
// nothing in ensureEncrypted, so provideDatabase's encrypted branch must itself have loaded
// SQLCipher's native library before the keyed open above could succeed.
verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() }
}
@Test
fun plaintextBranchNeverTouchesTheNativeLibrary() = runBlocking<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
mockkObject(DatabaseEncryption)
val database = DatabaseModule.provideDatabase(context, provisioner())
try {
database.messageDao().insertNew(listOf(message("acct:1")))
assertEquals(listOf("acct:1"), database.messageDao().observeSummaries().first().map { it.id })
} finally {
database.close()
}
// The counterpart to the guard above: the plaintext branch must never construct or load
// SQLCipher, so a regression that swapped the branch mapping would show up here too.
verify(exactly = 0) { DatabaseEncryption.ensureNativeLibraryLoaded() }
}
@Test
fun encryptedOpenNeverSucceedsIfTheNativeLibraryLoadFails() {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
seedEncryptedCacheWithOneRow()
mockkObject(DatabaseEncryption)
// Fault injection: if provideDatabase's encrypted branch ever stopped gating the keyed open on
// this load call, the open below would still succeed despite the injected failure. Failing here
// instead pins that the open is causally downstream of the load, not just usually preceded by it
// — the "wired without a preceding load" regression issue #220 calls out.
every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws IllegalStateException("boom")
val database = DatabaseModule.provideDatabase(context, provisioner())
try {
assertThrows(Throwable::class.java) {
runBlocking { database.messageDao().observeSummaries().first() }
}
} finally {
runCatching { database.close() }
}
}
}