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