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).
onForegroundLockAction 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.
DatabaseKeyStore's crypto is device-only (Android Keystore + a per-app DataStore file). Rather than instrument it, this adds a minimal @VisibleForTestingDataStore 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.
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
AppLockViewModelTestfrom the recovery-restart hardening.What's covered
AppLockViewModelTest(extended)onAuthenticatedunlock/arm classification — everyUnlockResult: 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).onForegroundLockActiondispatch —DISABLE_APP_LOCKpersists app-lock off;CLEAR_AND_DISABLEsets the pending flag and disables before the awaited re-sync enqueue + restart;CLEAR_AND_REQUIRE_AUTHclears + restarts but keeps app-lock on (assertssetAppLock(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(appLock on, encryptCache off, secure, valid) → REQUIRE_AUTHrow is now pinned, so a mutation toPROCEED(a silent lock bypass) fails.DatabaseKeyStoreTest(new, JVM)sealWithAuthdropsSEALED_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)setAppLockreject (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@VisibleForTestingDataStoreseam — mirroringAppLockViewModel's injectable dispatcher — so the seal exchange runs on the JVM against an in-memoryDataStorewith mocked ciphers. Production is unchanged (still resolves the real per-app DataStore); no crypto plumbing is refactored (that is #102) andDatabaseModuleis untouched.Verification
Full fast gate green locally on JDK 21:
assembleDebug,testDebugUnitTest,lintDebug,ktlintCheck,detekt, andcompileDebugAndroidTestKotlin. 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