P6 Stream T: finish BiometricLockScreen + BiometricManager

BiometricPrompt wrapper with 3-strike lockout + NO_HARDWARE bypass.
BiometricLockScreen with auto-prompt on mount + PIN fallback after 3 failures.
PIN wiring marked TODO for secure-storage follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:21:22 -05:00
parent 704c59e5cb
commit 46db133458
5 changed files with 829 additions and 42 deletions

View File

@@ -0,0 +1,281 @@
package com.tt.honeyDue.security
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import androidx.test.core.app.ApplicationProvider
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* P6 Stream T — Robolectric tests for [BiometricManager].
*
* We don't exercise the real [androidx.biometric.BiometricPrompt] system
* UI (can't show a prompt in a unit test). Instead we inject a fake
* [BiometricManager.Prompter] and drive the
* [BiometricPrompt.AuthenticationCallback] callbacks directly — this
* verifies our wrapper's result-mapping / strike-counting contract
* without needing on-device biometric hardware.
*/
@RunWith(RobolectricTestRunner::class)
class BiometricManagerTest {
private lateinit var activity: FragmentActivity
@Before
fun setUp() {
// ApplicationProvider gives us a Context; Robolectric can build a
// fragment-capable activity controller for testing FragmentActivity.
activity = Robolectric
.buildActivity(FragmentActivity::class.java)
.create()
.get()
}
// ---------- 1. canAuthenticate surfaces NO_HARDWARE ----------
@Test
fun canAuthenticate_returnsNoHardware_whenProbeSaysSo() {
val mgr = BiometricManager(
activity = activity,
promptFactory = { _ -> BiometricManager.Prompter { /* no-op */ } },
availabilityProbe = { BiometricManager.Availability.NO_HARDWARE },
)
assertEquals(BiometricManager.Availability.NO_HARDWARE, mgr.canAuthenticate())
}
@Test
fun canAuthenticate_returnsAvailable_whenProbeSaysSo() {
val mgr = BiometricManager(
activity = activity,
promptFactory = { _ -> BiometricManager.Prompter { } },
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
assertEquals(BiometricManager.Availability.AVAILABLE, mgr.canAuthenticate())
}
// ---------- 2. Success path ----------
@Test
fun authenticate_returnsSuccess_whenCallbackFiresSucceeded() = runTest {
var capturedCallback: BiometricPrompt.AuthenticationCallback? = null
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
capturedCallback = callback
BiometricManager.Prompter { _ ->
// Simulate user succeeding.
callback.onAuthenticationSucceeded(mockk(relaxed = true))
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock", subtitle = "Verify identity")
assertEquals(BiometricManager.Result.Success, result)
assertEquals(0, mgr.currentFailureCount(), "success resets strike counter")
// Sanity — the factory was actually invoked with our callback.
assertTrue(capturedCallback != null)
}
// ---------- 3. Three-strike lockout ----------
@Test
fun threeConsecutiveFailures_nextAuthenticateReturnsTooManyAttempts() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { _ ->
// Prompter is irrelevant here: we pre-seed the strike count
// to simulate three prior onAuthenticationFailed hits, then
// attempt one more call — it must short-circuit WITHOUT
// calling the prompter at all.
BiometricManager.Prompter { throw AssertionError("prompt must not be shown") }
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
mgr.seedFailures(3)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.TooManyAttempts, result)
}
@Test
fun failureCounter_incrementsAcrossMultipleFailedCallbacks() = runTest {
// Verifies that onAuthenticationFailed increments the internal
// counter even though it doesn't resume the coroutine. We simulate
// 3 failures followed by a terminal ERROR_USER_CANCELED so the
// suspend call actually resolves.
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationFailed()
callback.onAuthenticationFailed()
callback.onAuthenticationFailed()
callback.onAuthenticationError(
BiometricPrompt.ERROR_USER_CANCELED, "User canceled"
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.UserCanceled, result)
assertEquals(
3, mgr.currentFailureCount(),
"three onAuthenticationFailed events should bump strike count to 3",
)
// A follow-up call must now short-circuit to TooManyAttempts.
val secondAttempt = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.TooManyAttempts, secondAttempt)
}
// ---------- 4. USER_CANCELED maps to UserCanceled ----------
@Test
fun onAuthenticationError_userCanceled_mapsToUserCanceled() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_USER_CANCELED,
"User canceled",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.UserCanceled, result)
}
@Test
fun onAuthenticationError_negativeButton_mapsToUserCanceled() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
"Cancel",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.UserCanceled, result)
}
// ---------- 5. Hardware-absent error maps to NoHardware ----------
@Test
fun onAuthenticationError_hwNotPresent_mapsToNoHardware() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_HW_NOT_PRESENT,
"No hardware",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.NoHardware, result)
}
// ---------- 6. Lockout error maps to TooManyAttempts ----------
@Test
fun onAuthenticationError_lockout_mapsToTooManyAttemptsAndSaturatesCounter() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
BiometricPrompt.ERROR_LOCKOUT,
"Too many attempts",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
assertEquals(BiometricManager.Result.TooManyAttempts, result)
assertEquals(BiometricManager.MAX_FAILURES, mgr.currentFailureCount())
}
// ---------- 7. Other errors map to Result.Error with code + message ----------
@Test
fun onAuthenticationError_unknownError_mapsToResultError() = runTest {
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
callback.onAuthenticationError(
/* code = */ 9999,
/* msg = */ "Something went wrong",
)
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
val result = mgr.authenticate(title = "Unlock")
val err = assertIs<BiometricManager.Result.Error>(result)
assertEquals(9999, err.code)
assertEquals("Something went wrong", err.message)
}
// ---------- 8. reset() clears the strike counter ----------
@Test
fun reset_clearsFailureCounter_allowsFuturePromptsAgain() = runTest {
var promptsShown = 0
val mgr = BiometricManager(
activity = activity,
promptFactory = { callback ->
BiometricManager.Prompter { _ ->
promptsShown++
callback.onAuthenticationSucceeded(mockk(relaxed = true))
}
},
availabilityProbe = { BiometricManager.Availability.AVAILABLE },
)
mgr.seedFailures(BiometricManager.MAX_FAILURES)
// Before reset — locked out.
assertEquals(BiometricManager.Result.TooManyAttempts, mgr.authenticate("Unlock"))
assertEquals(0, promptsShown, "locked-out call must NOT show the prompt")
mgr.reset()
// After reset — prompt is allowed and resolves with success.
val afterReset = mgr.authenticate("Unlock")
assertEquals(BiometricManager.Result.Success, afterReset)
assertEquals(1, promptsShown)
}
}