test(security): cover the app-lock security core (seal exchange, unlock classification, policy table) #144

Merged
JMR-dev merged 2 commits from test-applock-security-core into main 2026-07-02 16:55:51 +00:00
JMR-dev commented 2026-07-02 16:42:31 +00:00 (Migrated from github.com)

Closes #100.

Adds JVM test coverage for the app-lock security core — the branching that decides when to WIPE user data or drop the lock, which shipped largely untested — plus a small testability seam. Extends (does not clobber) the existing AppLockViewModelTest from the recovery-restart hardening.

What's covered

AppLockViewModelTest (extended)

  • onAuthenticated unlock/arm classification — every UnlockResult: warm-session OK, sealed-passphrase unwrap OK, UNRECOVERABLE (auth key deleted / permanently invalidated → wipe + restart, app-lock kept on), RETRY (auth window elapsed / ambiguous unwrap failure / arm failure → re-lock, cache NOT wiped), first-time arm (encrypt on) vs. nothing-to-arm (encrypt off).
  • onForeground LockAction dispatch — DISABLE_APP_LOCK persists app-lock off; CLEAR_AND_DISABLE sets the pending flag and disables before the awaited re-sync enqueue + restart; CLEAR_AND_REQUIRE_AUTH clears + restarts but keeps app-lock on (asserts setAppLock(false) is never called); PROCEED/REQUIRE_AUTH/app-lock-off. Ordering is asserted against the current flow: pending flag → syncNow() Operation awaited → ProcessRestarter.restart().

KeyInvalidationPolicyTest

  • Replaces the hand-listed table with an exhaustive, data-driven 16-row truth table plus a completeness guard (fails if a row is ever dropped). The previously-unpinned (appLock on, encryptCache off, secure, valid) → REQUIRE_AUTH row is now pinned, so a mutation to PROCEED (a silent lock bypass) fails.

DatabaseKeyStoreTest (new, JVM)

  • Pins the dual-seal exchange invariants: sealWithAuth drops SEALED_MASTER (never both seals at once), sealWithMaster, resetSealedPassphrase, unlockWithAuth, and the clear-pending lifecycle. Pins not recoverable without auth (passphrase() refuses to mint a master key while an auth seal exists) and that an auth reseal reuses the same passphrase so an encrypted cache stays readable across a toggle.

SettingsViewModelTest (new, JVM)

  • setAppLock reject (no secure device) / reseal-before-disable (order asserted) / keep-lock-on-when-reseal-fails / no-seal-just-disable branches.

Testability seam

DatabaseKeyStore's crypto is device-only (Android Keystore + a per-app DataStore file). Rather than instrument it, this adds a minimal @VisibleForTesting DataStore seam — mirroring AppLockViewModel's injectable dispatcher — so the seal exchange runs on the JVM against an in-memory DataStore with mocked ciphers. Production is unchanged (still resolves the real per-app DataStore); no crypto plumbing is refactored (that is #102) and DatabaseModule is untouched.

Verification

Full fast gate green locally on JDK 21: assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, and compileDebugAndroidTestKotlin. New/changed test-class counts: DatabaseKeyStore 13, KeyInvalidationPolicy 8, AppLockViewModel 19, Settings 5 — all green. No product bug surfaced; the branching behaves as pinned.

🤖 Generated with Claude Code

Closes #100. Adds JVM test coverage for the app-lock **security core** — the branching that decides when to WIPE user data or drop the lock, which shipped largely untested — plus a small testability seam. Extends (does not clobber) the existing `AppLockViewModelTest` from the recovery-restart hardening. ## What's covered **`AppLockViewModelTest` (extended)** - `onAuthenticated` unlock/arm classification — every `UnlockResult`: warm-session OK, sealed-passphrase unwrap OK, UNRECOVERABLE (auth key deleted / permanently invalidated → wipe + restart, app-lock kept on), RETRY (auth window elapsed / ambiguous unwrap failure / arm failure → re-lock, cache NOT wiped), first-time arm (encrypt on) vs. nothing-to-arm (encrypt off). - `onForeground` `LockAction` dispatch — `DISABLE_APP_LOCK` persists app-lock off; `CLEAR_AND_DISABLE` sets the pending flag **and** disables before the awaited re-sync enqueue + restart; `CLEAR_AND_REQUIRE_AUTH` clears + restarts but **keeps app-lock on** (asserts `setAppLock(false)` is never called); `PROCEED`/`REQUIRE_AUTH`/app-lock-off. Ordering is asserted against the current flow: pending flag → `syncNow()` Operation awaited → `ProcessRestarter.restart()`. **`KeyInvalidationPolicyTest`** - Replaces the hand-listed table with an exhaustive, data-driven **16-row** truth table plus a completeness guard (fails if a row is ever dropped). The previously-unpinned `(appLock on, encryptCache off, secure, valid) → REQUIRE_AUTH` row is now pinned, so a mutation to `PROCEED` (a silent lock bypass) fails. **`DatabaseKeyStoreTest` (new, JVM)** - Pins the dual-seal exchange invariants: `sealWithAuth` drops `SEALED_MASTER` (**never both seals at once**), `sealWithMaster`, `resetSealedPassphrase`, `unlockWithAuth`, and the clear-pending lifecycle. Pins **not recoverable without auth** (`passphrase()` refuses to mint a master key while an auth seal exists) and that an auth reseal reuses the same passphrase so an encrypted cache stays readable across a toggle. **`SettingsViewModelTest` (new, JVM)** - `setAppLock` reject (no secure device) / reseal-before-disable (order asserted) / keep-lock-on-when-reseal-fails / no-seal-just-disable branches. ## Testability seam `DatabaseKeyStore`'s crypto is device-only (Android Keystore + a per-app DataStore file). Rather than instrument it, this adds a **minimal `@VisibleForTesting` `DataStore` seam** — mirroring `AppLockViewModel`'s injectable dispatcher — so the seal exchange runs on the JVM against an in-memory `DataStore` with mocked ciphers. Production is unchanged (still resolves the real per-app DataStore); no crypto plumbing is refactored (that is #102) and `DatabaseModule` is untouched. ## Verification Full fast gate green locally on JDK 21: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, and `compileDebugAndroidTestKotlin`. New/changed test-class counts: DatabaseKeyStore 13, KeyInvalidationPolicy 8, AppLockViewModel 19, Settings 5 — all green. No product bug surfaced; the branching behaves as pinned. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.