Files
honeyDueKMP/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/security/BiometricManagerTest.kt
Trey T 46db133458 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>
2026-04-18 13:21:22 -05:00

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)
}
}