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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user