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>
282 lines
10 KiB
Kotlin
282 lines
10 KiB
Kotlin
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)
|
|
}
|
|
}
|