test(reporting): ReportUpload testability seams + push IdleService E2E #283

Merged
JMR-dev merged 2 commits from test-257-reportupload-seams-e2e into main 2026-07-04 05:16:33 +00:00
3 changed files with 335 additions and 0 deletions
Showing only changes of commit eda5103da2 - Show all commits
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import android.net.Uri
import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.coldopen.ColdOpenCacheProbe
import org.libremail.data.local.entity.MessageEntity
import java.io.File
/**
* Issue #221: a **process-isolated** cold open of a pre-encrypted cache — the SQLCipher regression fixed
* in 592a797. The crash only surfaced on a cold process opening an already-encrypted cache with nothing
* to convert, where the keyed open reached `SQLiteConnection.nativeOpen` with the native `.so` unloaded
* and threw `UnsatisfiedLinkError`. Every existing on-device test ([DatabaseEncryptionTest],
* [DatabaseProvisionerInstrumentedTest], `DatabaseModuleInstrumentedTest`, `AccountDataMigratorTest`)
* runs a conversion first, which loads the process-global library in-process — masking the bug exactly
* as production did before the fix.
*
* Process isolation is unavoidable here: `System.loadLibrary` is process-global, so once THIS
* (instrumentation) process mints the encrypted fixture, it can no longer observe a cold open. The
* fixture is therefore opened in a separate `:coldopen` app process hosted by [ColdOpenCacheProbe] (a
* debug-only [android.content.ContentProvider]); this test mints the fixture here — "a file created by a
* prior encrypted DB instance" — and drives the cold open there via `ContentResolver.call`, which spins
* that pristine process up on demand. The harness [ColdOpenCacheProbe.KEY_COLD_PROBE] check makes the
* isolation self-verifying: if the library were already loaded in the harness process, this test fails
* rather than passing a hollow assertion.
*/
@RunWith(AndroidJUnit4::class)
class ColdOpenEncryptedCacheTest {
private val appContext = ApplicationProvider.getApplicationContext<Context>()
private val dbName = "coldopen_encrypted_cache_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)
@Before
@After
fun clean() {
appContext.deleteDatabase(dbName)
dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() }
}
@Test
fun coldProcessOpensAPreEncryptedCacheWithoutUnsatisfiedLinkError() {
// Mint a real SQLCipher-encrypted cache with one row HERE, in the instrumentation process. This
// loads the process-global .so in THIS process — precisely why the cold open must be observed in a
// different, pristine process.
seedEncryptedFixture()
assertTrue("precondition: the fixture is genuinely encrypted", DatabaseEncryption.isEncrypted(dbFile))
val result = callColdOpenProcess()
assertNotNull("the :coldopen process returned no result", result)
val probe = result?.getString(ColdOpenCacheProbe.KEY_COLD_PROBE)
val open = result?.getString(ColdOpenCacheProbe.KEY_OPEN)
// The harness proves its own process is genuinely cold: a keyed open with no preceding
// System.loadLibrary must fail at nativeOpen. Anything else means the .so was already loaded there
// and the "cold" open would be meaningless — so fail loudly instead of passing a hollow assertion.
assertEquals(
"the :coldopen process was not actually cold (SQLCipher already loaded); isolation broke — open=$open",
ColdOpenCacheProbe.PROBE_UNSATISFIED_LINK,
probe,
)
// The headline #221 guard: opening the pre-encrypted cache through the production wiring on a cold
// process succeeds (no UnsatisfiedLinkError) and reads the seeded row back.
assertEquals(
"cold open of the pre-encrypted cache failed (probe=$probe)",
ColdOpenCacheProbe.OPEN_OK,
open,
)
}
private fun callColdOpenProcess(): Bundle? {
val authority = appContext.packageName + ColdOpenCacheProbe.AUTHORITY_SUFFIX
return appContext.contentResolver.call(
Uri.parse("content://$authority"),
ColdOpenCacheProbe.METHOD_COLD_OPEN,
null,
bundleOf(
ColdOpenCacheProbe.KEY_DB_NAME to dbName,
ColdOpenCacheProbe.KEY_PASSPHRASE to passphrase,
),
)
}
/**
* Builds a plaintext cache with one row and converts it to real SQLCipher ciphertext — the steady-
* state shape an already-encrypted install presents at the next cold start, where `ensureEncrypted`
* has nothing left to convert (592a797's exact failure mode).
*/
private fun seedEncryptedFixture() {
Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build().apply {
runBlocking { messageDao().insertNew(listOf(message(ColdOpenCacheProbe.EXPECTED_ROW_ID))) }
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,
)
}
+21
View File
@@ -0,0 +1,21 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
Debug-only test harness for issue #221 (never merged into a release APK — this manifest belongs to
the debug source set). Hosts ColdOpenCacheProbe in a dedicated ":coldopen" process so an
instrumented test can observe a GENUINE cold open of a pre-encrypted cache: a process where nothing
has yet loaded SQLCipher's process-global native library. The instrumentation process can't offer
that — minting the encrypted fixture (or any earlier test) loads the .so there, masking the 592a797
nativeOpen crash. The provider is exported="false" and inert unless ContentResolver.call() targets
it, so it has no effect on ordinary debug runs.
-->
<application>
<provider
android:name="org.libremail.data.local.coldopen.ColdOpenCacheProbe"
android:authorities="${applicationId}.coldopen"
android:exported="false"
android:process=":coldopen" />
</application>
</manifest>
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.coldopen
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import androidx.room.Room
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.DeferredOpenHelperFactory
import org.libremail.data.local.LibreMailDatabase
/**
* Test-only harness for issue #221. Lives in the **debug** source set (never in a release APK) and is
* declared in the debug manifest with `android:process=":coldopen"`, so its work runs in a SEPARATE app
* process from the instrumentation process.
*
* SQLCipher's `System.loadLibrary("sqlcipher")` is process-global: once the instrumentation process mints
* the encrypted fixture (or any earlier test opens a keyed database), the `.so` is loaded there and a
* later "cold open" can no longer be observed in that process. That is the exact blind spot behind the
* 592a797 crash — a cold start opening an already-encrypted cache with nothing to convert, where the
* keyed open reaches `SQLiteConnection.nativeOpen` with the library unloaded and throws
* `UnsatisfiedLinkError`. This provider gives the test a pristine process to reproduce it.
*
* [call] runs in the `:coldopen` process and reports two things back in a [Bundle]:
* 1. [KEY_COLD_PROBE] — proof the process is genuinely cold: a keyed open with NO preceding
* `System.loadLibrary` must fail at `nativeOpen`. Anything else means the `.so` was already loaded
* here, so the isolation premise broke and the "cold" open below would be meaningless.
* 2. [KEY_OPEN] — the result of opening the pre-encrypted cache exactly the way production does on a
* steady-state encrypted start ([DatabaseProvisioner]'s encrypted branch + [DatabaseModule]'s open
* lambda): [DatabaseEncryption.ensureEncrypted] (a no-op on an already-encrypted file),
* [DatabaseEncryption.ensureNativeLibraryLoaded] (the 592a797 fix), then a Room open through a
* [DeferredOpenHelperFactory] wrapping [SupportOpenHelperFactory]. Reports whether the seeded row
* reads back.
*
* The `DatabaseProvisioner`/`DatabaseModule` objects themselves are not invoked here because they require
* MockK-substituted collaborators (`DatabaseKeyStore`/`SettingsRepository`/`AccountDataMigrator` are
* final and read the real Keystore/DataStore), and the test APK — hence MockK — is not on a forked app
* process's classloader. The provisioner's encrypted-branch decision is instead mirrored line-for-line,
* against the real `DatabaseEncryption`, `DeferredOpenHelperFactory` and `SupportOpenHelperFactory`.
*/
class ColdOpenCacheProbe : ContentProvider() {
override fun onCreate(): Boolean = true
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
val out = Bundle()
if (method != METHOD_COLD_OPEN) {
out.putString(KEY_ERROR, "unknown method: $method")
return out
}
val appContext = requireNotNull(context).applicationContext
val bundle = requireNotNull(extras) { "cold-open call requires extras" }
val dbName = requireNotNull(bundle.getString(KEY_DB_NAME)) { "missing $KEY_DB_NAME" }
val passphrase = requireNotNull(bundle.getString(KEY_PASSPHRASE)) { "missing $KEY_PASSPHRASE" }
out.putString(KEY_COLD_PROBE, probeKeyedOpenWithoutLoad(appContext))
out.putString(KEY_OPEN, openThroughProductionWiring(appContext, dbName, passphrase))
return out
}
/**
* A keyed open with NO preceding `System.loadLibrary` — the exact call the pre-592a797 provisioner
* reached with the `.so` unloaded. In a genuinely cold process this hits `nativeOpen` and throws
* [UnsatisfiedLinkError]. Runs against a THROWAWAY file so a partially-created database can never
* disturb the real fixture opened afterwards.
*/
private fun probeKeyedOpenWithoutLoad(appContext: Context): String {
val probeName = "coldopen_probe_$PROBE_NONCE.db"
return try {
val helper = SupportOpenHelperFactory(PROBE_KEY.toByteArray(Charsets.US_ASCII), null, false)
.create(noOpConfiguration(appContext, probeName))
try {
helper.writableDatabase // forces the keyed nativeOpen — the 592a797 crash surface
PROBE_OPENED_UNEXPECTEDLY
} finally {
runCatching { helper.close() }
}
} catch (t: Throwable) {
if (hasUnsatisfiedLink(t)) PROBE_UNSATISFIED_LINK else "$PROBE_OTHER${t.javaClass.name}"
} finally {
appContext.deleteDatabase(probeName)
}
}
/**
* Opens the pre-encrypted fixture the same way production does on a steady-state encrypted start,
* against the real production classes. Returns [OPEN_OK] iff the keyed open succeeds cold and the
* seeded row reads back.
*/
private fun openThroughProductionWiring(appContext: Context, dbName: String, passphrase: String): String {
val dbFile = appContext.getDatabasePath(dbName)
return try {
// Mirror DatabaseProvisioner's encrypted branch: the conversion is a no-op on an already-
// encrypted file, so the explicit native-library load is what lets the keyed open succeed.
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
DatabaseEncryption.ensureNativeLibraryLoaded()
val keyBytes = passphrase.toByteArray(Charsets.US_ASCII)
val database = Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName)
// Mirror DatabaseModule.provideDatabase's encrypted open lambda exactly.
.openHelperFactory(
DeferredOpenHelperFactory { configuration ->
SupportOpenHelperFactory(keyBytes, null, false).create(configuration)
},
)
.build()
try {
val ids = runBlocking { database.messageDao().observeSummaries().first().map { it.id } }
if (ids == listOf(EXPECTED_ROW_ID)) OPEN_OK else "$OPEN_ROWS$ids"
} finally {
database.close()
}
} catch (t: Throwable) {
"$OPEN_FAIL${t.javaClass.name}: ${t.message}"
}
}
private fun noOpConfiguration(appContext: Context, name: String): SupportSQLiteOpenHelper.Configuration =
SupportSQLiteOpenHelper.Configuration.builder(appContext)
.name(name)
.callback(object : SupportSQLiteOpenHelper.Callback(CALLBACK_VERSION) {
override fun onCreate(db: SupportSQLiteDatabase) = Unit
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
override fun onDowngrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
})
.build()
// Unused ContentProvider surface — this provider exists only for its process-isolated call().
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int =
0
companion object {
/** Appended to the app's `applicationId` to form the provider authority (see the debug manifest). */
const val AUTHORITY_SUFFIX = ".coldopen"
const val METHOD_COLD_OPEN = "coldOpen"
const val KEY_DB_NAME = "dbName"
const val KEY_PASSPHRASE = "passphrase"
const val KEY_COLD_PROBE = "coldProbe"
const val KEY_OPEN = "open"
const val KEY_ERROR = "error"
/** The id of the single row the fixture is seeded with; the cold open must read it back. */
const val EXPECTED_ROW_ID = "acct:1"
const val PROBE_UNSATISFIED_LINK = "UNSATISFIED_LINK"
const val PROBE_OPENED_UNEXPECTEDLY = "OPENED_UNEXPECTEDLY"
const val PROBE_OTHER = "OTHER:"
const val OPEN_OK = "OK"
const val OPEN_FAIL = "FAIL:"
const val OPEN_ROWS = "ROWS:"
private const val PROBE_KEY = "coldopenprobe"
private const val CALLBACK_VERSION = 1
private val PROBE_NONCE = System.nanoTime()
private fun hasUnsatisfiedLink(throwable: Throwable): Boolean {
var current: Throwable? = throwable
while (current != null) {
if (current is UnsatisfiedLinkError) return true
current = current.cause
}
return false
}
}
}