test(db): instrumented cold-open of a pre-encrypted cache #282

Merged
JMR-dev merged 2 commits from test-221-cold-open-encrypted-cache into main 2026-07-04 04:47:44 +00:00
JMR-dev commented 2026-07-04 04:31:20 +00:00 (Migrated from github.com)

Closes #221

What

Adds an instrumented regression test for the SQLCipher cold-start crash fixed in 592a797 (bug #210): a cold process opening an already-encrypted cache with nothing to convert reached Room's keyed nativeOpen with the native .so unloaded and crash-looped with UnsatisfiedLinkError.

Every existing on-device test (DatabaseEncryptionTest, DatabaseProvisionerInstrumentedTest, the just-merged DatabaseModuleInstrumentedTest, AccountDataMigratorTest) runs a plaintext→encrypted conversion first, which calls System.loadLibrary("sqlcipher") in-process — so their keyed opens are never cold, masking the bug exactly as production did. This is the one state no other test constructs.

Why a separate process (the app/src/debug/ harness)

System.loadLibrary is process-global: once any test in a run loads sqlcipher, a later test in the same process can't observe the "not loaded" state. Minting the encrypted fixture itself loads the .so. So a faithful cold open cannot be observed in the instrumentation process — it must happen in a pristine process.

app/src/debug/ (debug source set → never in a release APK) contains:

  • ColdOpenCacheProbe.kt — a ContentProvider declared in the debug manifest with android:process=":coldopen", so its call() runs in a separate app process. It (1) proves that process is genuinely cold — a keyed open with no preceding load must fail at nativeOpen with UnsatisfiedLinkError — then (2) opens the pre-encrypted cache exactly the way production does on a steady-state encrypted start (DatabaseEncryption.ensureEncrypted no-op → ensureNativeLibraryLoaded → Room open through a DeferredOpenHelperFactory wrapping SupportOpenHelperFactory), and reports whether the seeded row reads back.
  • AndroidManifest.xml — declares that provider in the :coldopen process (exported=false, inert unless targeted).

ColdOpenEncryptedCacheTest mints the encrypted fixture in the instrumentation process ("a file created by a prior encrypted DB instance") and drives the cold open in :coldopen via ContentResolver.call, which spins that pristine process up on demand. The cold-probe check makes the isolation self-verifying: if the library were already loaded in the harness process, the test fails rather than passing a hollow assertion. No orchestrator / dedicated CI run / managed-device change is required — the fork is fresh regardless of what the shared connectedDebugAndroidTest process loaded.

Fidelity note

The harness mirrors DatabaseProvisioner's encrypted-branch decision + DatabaseModule's open lambda against the real DatabaseEncryption, DeferredOpenHelperFactory and SupportOpenHelperFactory, rather than invoking the DatabaseProvisioner/DatabaseModule objects directly. Those 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 object-level "load precedes keyed open" invariant is already pinned non-cold by DatabaseProvisionerInstrumentedTest and DatabaseModuleInstrumentedTest (#220); this test adds the missing cold-process coverage.

Test result

Local connectedDebugAndroidTest (API 36 emulator, manual cold boot), class-filtered: 1 test, 0 failures — cold probe returned UNSATISFIED_LINK (process genuinely cold) and the production-wiring open returned OK (seeded row read back).

🤖 Generated with Claude Code

Closes #221 ## What Adds an instrumented regression test for the SQLCipher cold-start crash fixed in `592a797` (bug #210): a **cold process opening an already-encrypted cache with nothing to convert** reached Room's keyed `nativeOpen` with the native `.so` unloaded and crash-looped with `UnsatisfiedLinkError`. Every existing on-device test (`DatabaseEncryptionTest`, `DatabaseProvisionerInstrumentedTest`, the just-merged `DatabaseModuleInstrumentedTest`, `AccountDataMigratorTest`) runs a plaintext→encrypted conversion first, which calls `System.loadLibrary("sqlcipher")` in-process — so their keyed opens are never cold, masking the bug exactly as production did. This is the one state no other test constructs. ## Why a separate process (the `app/src/debug/` harness) `System.loadLibrary` is **process-global**: once any test in a run loads `sqlcipher`, a later test in the same process can't observe the "not loaded" state. Minting the encrypted fixture itself loads the `.so`. So a faithful cold open **cannot** be observed in the instrumentation process — it must happen in a pristine process. `app/src/debug/` (debug source set → **never in a release APK**) contains: - **`ColdOpenCacheProbe.kt`** — a `ContentProvider` declared in the debug manifest with `android:process=":coldopen"`, so its `call()` runs in a **separate app process**. It (1) proves that process is genuinely cold — a keyed open with no preceding load must fail at `nativeOpen` with `UnsatisfiedLinkError` — then (2) opens the pre-encrypted cache exactly the way production does on a steady-state encrypted start (`DatabaseEncryption.ensureEncrypted` no-op → `ensureNativeLibraryLoaded` → Room open through a `DeferredOpenHelperFactory` wrapping `SupportOpenHelperFactory`), and reports whether the seeded row reads back. - **`AndroidManifest.xml`** — declares that provider in the `:coldopen` process (`exported=false`, inert unless targeted). `ColdOpenEncryptedCacheTest` mints the encrypted fixture in the instrumentation process (**"a file created by a prior encrypted DB instance"**) and drives the cold open in `:coldopen` via `ContentResolver.call`, which spins that pristine process up on demand. The **cold-probe check makes the isolation self-verifying**: if the library were already loaded in the harness process, the test fails rather than passing a hollow assertion. No orchestrator / dedicated CI run / managed-device change is required — the fork is fresh regardless of what the shared `connectedDebugAndroidTest` process loaded. ### Fidelity note The harness mirrors `DatabaseProvisioner`'s encrypted-branch decision + `DatabaseModule`'s open lambda against the **real** `DatabaseEncryption`, `DeferredOpenHelperFactory` and `SupportOpenHelperFactory`, rather than invoking the `DatabaseProvisioner`/`DatabaseModule` objects directly. Those 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 object-level "load precedes keyed open" invariant is already pinned non-cold by `DatabaseProvisionerInstrumentedTest` and `DatabaseModuleInstrumentedTest` (#220); this test adds the missing **cold-process** coverage. ## Test result Local `connectedDebugAndroidTest` (API 36 emulator, manual cold boot), class-filtered: **1 test, 0 failures** — cold probe returned `UNSATISFIED_LINK` (process genuinely cold) and the production-wiring open returned `OK` (seeded row read back). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.