The lock's ViewModels and biometric path have no tests #65

Closed
opened 2026-08-20 21:22:37 -05:00 by null · 1 comment
Owner

What is true now. core/security is well covered (AppLockRepositoryTest, PinVerifierTest, LockoutPolicyTest, instrumented KeystoreVerifierTest), and app has AppLockControllerTest and LockCopyTest. Everything between them is untested: AppLockViewModel (no test file at all — the LockState derivation, the five UnlockResult branches, relock() state reset, deliverPendingNotificationAction), LockSettingsViewModel (none — which is why the change-PIN bypass went unnoticed), BiometricUnlock (none — the availability matrix, every error branch, the persistence side-effect), AppLockGate (none — the ON_STOP / isChangingConfigurations / authInProgress guard matrix), and LockEraseViewModel's mandated ordering. There is no app/src/androidTest directory and no compose-ui-test dependency (app/build.gradle.kts:108-113). Settings and setup copy is unguarded — LockCopyTest covers only the lock screen's LockCopy.

What it costs. docs/qa/ClaudeReport.md records the standing judgment that every defect of consequence in this project was found by a person using the app and none by a test. The lock is the feature where that is most expensive.

What to do. AppLockViewModelTest and LockSettingsViewModelTest (Robolectric, real repositories over temp DataStores, using the small public (store, macs) seam on AppLockRepository); a pure BiometricDecisionTest over every prompt error code once the decision logic is extracted from the callbacks; BiometricAvailabilityTest using Robolectric's ShadowBiometricManager; LockEraseViewModelTest for the erase ordering; and a LockSettingsCopyTest extending the LockCopyTest pattern to the settings and setup strings.

Traps. AppLockRepository's three-argument constructor, Clocks and core/security's FakeMacProvider are all internal and unreachable from app/src/test; the one-argument constructor needs the Android Keystore, which does not exist on the host JVM. A small public two-argument seam is the minimum honest change. Robolectric's ShadowBiometricManager has a quirk — a non-STRONG authenticator type is returned as the status code — comment it in the test so nobody 'fixes' it.

Verify: ./gradlew :app:testDebugUnitTest runs the named ViewModel, decision and copy tests, and scripts/prove-guard.sh reddens exactly one test for each of two mutations (the migration's BIOMETRIC guard; the biometric no-enrolment branch).

**What is true now.** `core/security` is well covered (`AppLockRepositoryTest`, `PinVerifierTest`, `LockoutPolicyTest`, instrumented `KeystoreVerifierTest`), and `app` has `AppLockControllerTest` and `LockCopyTest`. Everything between them is untested: `AppLockViewModel` (no test file at all — the LockState derivation, the five UnlockResult branches, `relock()` state reset, `deliverPendingNotificationAction`), `LockSettingsViewModel` (none — which is why the change-PIN bypass went unnoticed), `BiometricUnlock` (none — the availability matrix, every error branch, the persistence side-effect), `AppLockGate` (none — the ON_STOP / isChangingConfigurations / authInProgress guard matrix), and `LockEraseViewModel`'s mandated ordering. There is no `app/src/androidTest` directory and no compose-ui-test dependency (`app/build.gradle.kts:108-113`). Settings and setup copy is unguarded — `LockCopyTest` covers only the lock screen's `LockCopy`. **What it costs.** `docs/qa/ClaudeReport.md` records the standing judgment that every defect of consequence in this project was found by a person using the app and none by a test. The lock is the feature where that is most expensive. **What to do.** `AppLockViewModelTest` and `LockSettingsViewModelTest` (Robolectric, real repositories over temp DataStores, using the small public `(store, macs)` seam on `AppLockRepository`); a pure `BiometricDecisionTest` over every prompt error code once the decision logic is extracted from the callbacks; `BiometricAvailabilityTest` using Robolectric's `ShadowBiometricManager`; `LockEraseViewModelTest` for the erase ordering; and a `LockSettingsCopyTest` extending the `LockCopyTest` pattern to the settings and setup strings. **Traps.** `AppLockRepository`'s three-argument constructor, `Clocks` and `core/security`'s `FakeMacProvider` are all `internal` and unreachable from `app/src/test`; the one-argument constructor needs the Android Keystore, which does not exist on the host JVM. A small public two-argument seam is the minimum honest change. Robolectric's `ShadowBiometricManager` has a quirk — a non-STRONG authenticator type is returned as the status code — comment it in the test so nobody 'fixes' it. Verify: `./gradlew :app:testDebugUnitTest` runs the named ViewModel, decision and copy tests, and `scripts/prove-guard.sh` reddens exactly one test for each of two mutations (the migration's BIOMETRIC guard; the biometric no-enrolment branch).
null added this to the Batch 11 — Settings and the App Lock milestone 2026-08-20 21:22:37 -05:00
null added the
P2
label 2026-08-20 21:22:37 -05:00
Author
Owner

Closed by the same work as #63, which is where the tests had to come from — each rule needed its harness before it could be proved.

AppLockViewModel's lock-state derivation is now an extracted lockStateOf(method, unlocked) asserted directly in LockSettingsViewModelTest's gate test, which collects the flow throughout a PIN being set and asserts Locked never appears — the race that used to throw a user to the lock screen seconds after choosing a PIN.

LockSettingsViewModel: 11 tests covering every transition (off→PIN, off→either, PIN→off with a wrong PIN refused, PIN→either, PIN→fingerprint-only, abandoning, choosing the method already in force), including that a failed scan writes nothing and that consent alone commits nothing.

LockMethodMigration: 9 tests including the never-PIN-less invariant across all four legacy shapes, idempotency, not overwriting a chosen method, and an unreadable preferences file still leaving a PIN path.

The biometric path is covered where it is decidable off-device: the outcome handling is a pure branch on the ViewModel (onBiometricOutcome), tested in both directions, and the persistence side-effect is gone entirely — no biometric result writes anything in fingerprint-only mode, which was the bypass risk. The prompt itself (BiometricPrompt construction, availability codes) remains a device row in SECURITY_CHECKLIST.md, now with four new entries, because Robolectric's ShadowBiometricManager cannot exercise a real sensor.

Copy is guarded by LockCopyTest (3) and the new LockSettingsCopyTest (5). Back behaviour by LockSettingsBackTest (4) and ForgotPinBackTest (1). AppLockControllerTest (6) unchanged and still green.

Verify: ./gradlew :app:testDebugUnitTest :core:security:testDebugUnitTest — 115 and 44 tests, all green.

Closed by the same work as #63, which is where the tests had to come from — each rule needed its harness before it could be proved. AppLockViewModel's lock-state derivation is now an extracted lockStateOf(method, unlocked) asserted directly in LockSettingsViewModelTest's gate test, which collects the flow throughout a PIN being set and asserts Locked never appears — the race that used to throw a user to the lock screen seconds after choosing a PIN. LockSettingsViewModel: 11 tests covering every transition (off→PIN, off→either, PIN→off with a wrong PIN refused, PIN→either, PIN→fingerprint-only, abandoning, choosing the method already in force), including that a failed scan writes nothing and that consent alone commits nothing. LockMethodMigration: 9 tests including the never-PIN-less invariant across all four legacy shapes, idempotency, not overwriting a chosen method, and an unreadable preferences file still leaving a PIN path. The biometric path is covered where it is decidable off-device: the outcome handling is a pure branch on the ViewModel (onBiometricOutcome), tested in both directions, and the persistence side-effect is gone entirely — no biometric result writes anything in fingerprint-only mode, which was the bypass risk. The prompt itself (BiometricPrompt construction, availability codes) remains a device row in SECURITY_CHECKLIST.md, now with four new entries, because Robolectric's ShadowBiometricManager cannot exercise a real sensor. Copy is guarded by LockCopyTest (3) and the new LockSettingsCopyTest (5). Back behaviour by LockSettingsBackTest (4) and ForgotPinBackTest (1). AppLockControllerTest (6) unchanged and still green. Verify: ./gradlew :app:testDebugUnitTest :core:security:testDebugUnitTest — 115 and 44 tests, all green.
null closed this issue 2026-08-21 01:53:55 -05:00
Sign in to join this conversation.
No Label
P0
P1
P2
release-blocker
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: null/Privacy-Period-Tracker#65
No description provided.