BIT-189 Check for data breaches during create account (#154)

This commit is contained in:
Andrew Haisting
2023-10-24 11:36:11 -05:00
committed by GitHub
parent 4d93147186
commit fa4cd603b4
15 changed files with 577 additions and 84 deletions

View File

@@ -0,0 +1,48 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.HaveIBeenPwnedApi
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class HaveIBeenPwnedServiceTest : BaseServiceTest() {
private val haveIBeenPwnedApi: HaveIBeenPwnedApi = retrofit.create()
private val service = HaveIBeenPwnedServiceImpl(haveIBeenPwnedApi)
@Test
fun `when service returns failure should return failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
assertTrue(service.hasPasswordBeenBreached(PWNED_PASSWORD).isFailure)
}
@Test
fun `when given password is in response returns true`() = runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.hasPasswordBeenBreached(PWNED_PASSWORD)
assertTrue(result.getOrThrow())
}
@Test
fun `when given password is not in response returns false`() = runTest {
val response = MockResponse().setBody(HIBP_RESPONSE)
server.enqueue(response)
val result = service.hasPasswordBeenBreached("testpassword")
assertFalse(result.getOrThrow())
}
}
private const val PWNED_PASSWORD = "password1234"
private val HIBP_RESPONSE = """
FBD6D76BB5D2041542D7D2E3FAC5BB05593:36865
F390F21EBEFEF07A1DA4E661AF830FD76A6:3
F3CAEF537A4881A05E2A9A9A8A236FE7C14:1
F44FD6981B10EC24A93989A0C61E71C767C:5
""".trimIndent()

View File

@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
@@ -22,6 +23,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@@ -45,6 +47,7 @@ class AuthRepositoryTest {
private val accountsService: AccountsService = mockk()
private val identityService: IdentityService = mockk()
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val authSdkSource = mockk<AuthSdkSource> {
coEvery {
@@ -76,6 +79,7 @@ class AuthRepositoryTest {
private val repository = AuthRepositoryImpl(
accountsService = accountsService,
identityService = identityService,
haveIBeenPwnedService = haveIBeenPwnedService,
authSdkSource = authSdkSource,
authDiskSource = fakeAuthDiskSource,
dispatcher = UnconfinedTestDispatcher(),
@@ -83,7 +87,7 @@ class AuthRepositoryTest {
@BeforeEach
fun beforeEach() {
clearMocks(identityService, accountsService)
clearMocks(identityService, accountsService, haveIBeenPwnedService)
mockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH)
}
@@ -230,6 +234,72 @@ class AuthRepositoryTest {
}
}
@Test
fun `register check data breaches error should return Error`() = runTest {
coEvery {
haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD)
} returns Result.failure(Throwable())
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
assertEquals(RegisterResult.Error(null), result)
}
@Test
fun `register check data breaches found should return DataBreachFound`() = runTest {
coEvery {
haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD)
} returns true.asSuccess()
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
assertEquals(RegisterResult.DataBreachFound, result)
}
@Test
fun `register check data breaches Success should return Success`() = runTest {
coEvery {
haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD)
} returns false.asSuccess()
coEvery {
accountsService.register(
body = RegisterRequestJson(
email = EMAIL,
masterPasswordHash = PASSWORD_HASH,
masterPasswordHint = null,
captchaResponse = null,
key = ENCRYPTED_USER_KEY,
keys = RegisterRequestJson.Keys(
publicKey = PUBLIC_KEY,
encryptedPrivateKey = PRIVATE_KEY,
),
kdfType = PBKDF2_SHA256,
kdfIterations = DEFAULT_KDF_ITERATIONS.toUInt(),
),
)
} returns Result.success(RegisterResponseJson.Success(captchaBypassToken = CAPTCHA_KEY))
val result = repository.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
assertEquals(RegisterResult.Success(CAPTCHA_KEY), result)
coVerify { haveIBeenPwnedService.hasPasswordBeenBreached(PASSWORD) }
}
@Test
fun `register Success should return Success`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
@@ -256,6 +326,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.Success(CAPTCHA_KEY), result)
}
@@ -295,6 +366,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.Error(errorMessage = null), result)
}
@@ -334,6 +406,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.CaptchaRequired(captchaId = CAPTCHA_KEY), result)
}
@@ -364,6 +437,7 @@ class AuthRepositoryTest {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
assertEquals(RegisterResult.Error(errorMessage = null), result)
}

View File

@@ -27,7 +27,6 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@@ -312,9 +311,11 @@ class CreateAccountScreenTest : BaseComposeTest() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(
errorDialogState = BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
),
)
@@ -335,14 +336,62 @@ class CreateAccountScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) }
}
@Test
fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(dialog = CreateAccountDialog.HaveIBeenPwned),
)
every { eventFlow } returns emptyFlow()
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onAllNodesWithText("No")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CreateAccountAction.ErrorDialogDismiss) }
}
@Test
fun `clicking Yes on the HIBP dialog should send ContinueWithBreachedPasswordClick action`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(dialog = CreateAccountDialog.HaveIBeenPwned),
)
every { eventFlow } returns emptyFlow()
every { trySendAction(CreateAccountAction.ErrorDialogDismiss) } returns Unit
}
composeTestRule.setContent {
CreateAccountScreen(
onNavigateBack = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick) }
}
@Test
fun `when BasicDialogState is Shown should show dialog`() {
val viewModel = mockk<CreateAccountViewModel>(relaxed = true) {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(
errorDialogState = BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
),
)
@@ -407,8 +456,7 @@ class CreateAccountScreenTest : BaseComposeTest() {
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
}
}

View File

@@ -17,8 +17,8 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Pas
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -70,8 +70,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "hint",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
val handle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = CreateAccountViewModel(
@@ -91,9 +90,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@@ -112,10 +113,12 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
viewModel.trySendAction(EmailInputChange(input))
val expectedState = DEFAULT_STATE.copy(
emailInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@@ -136,9 +139,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
val expectedState = DEFAULT_STATE.copy(
emailInput = EMAIL,
passwordInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@@ -159,9 +164,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
val expectedState = DEFAULT_STATE.copy(
emailInput = "test@test.com",
passwordInput = input,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@@ -184,9 +191,11 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
emailInput = "test@test.com",
passwordInput = password,
confirmPasswordInput = password,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.accept_policies_error.asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.accept_policies_error.asText(),
),
),
)
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
@@ -205,6 +214,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Success(captchaToken = "mock_token")
}
@@ -218,11 +228,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(
VALID_INPUT_STATE.copy(
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
@@ -247,6 +253,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Error(errorMessage = "mock_error")
}
@@ -258,19 +265,16 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
assertEquals(
VALID_INPUT_STATE.copy(
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
),
VALID_INPUT_STATE.copy(dialog = CreateAccountDialog.Loading),
awaitItem(),
)
assertEquals(
VALID_INPUT_STATE.copy(
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
dialog = CreateAccountDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
),
),
),
awaitItem(),
@@ -292,6 +296,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.CaptchaRequired(captchaId = "mock_captcha_id")
}
@@ -322,6 +327,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Success(captchaToken = "mock_captcha_token")
}
@@ -341,6 +347,69 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
}
}
@Test
@Suppress("MaxLineLength")
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
} returns RegisterResult.Error(null)
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.trySendAction(CreateAccountAction.ContinueWithBreachedPasswordClick)
coVerify {
repo.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = false,
)
}
}
@Test
fun `SubmitClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() =
runTest {
val repo = mockk<AuthRepository> {
every { captchaTokenResultFlow } returns flowOf()
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
captchaToken = null,
shouldCheckDataBreaches = true,
)
} returns RegisterResult.DataBreachFound
}
val viewModel = CreateAccountViewModel(
savedStateHandle = validInputHandle,
authRepository = repo,
)
viewModel.actionChannel.trySend(CreateAccountAction.CheckDataBreachesToggle(true))
viewModel.actionChannel.trySend(CreateAccountAction.SubmitClick)
viewModel.stateFlow.test {
assertEquals(
VALID_INPUT_STATE.copy(
isCheckDataBreachesToggled = true,
dialog = CreateAccountDialog.HaveIBeenPwned,
),
awaitItem(),
)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = CreateAccountViewModel(
@@ -459,8 +528,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
private val VALID_INPUT_STATE = CreateAccountState(
passwordInput = PASSWORD,
@@ -469,8 +537,7 @@ class CreateAccountViewModelTest : BaseViewModelTest() {
passwordHintInput = "",
isCheckDataBreachesToggled = false,
isAcceptPoliciesToggled = true,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
dialog = null,
)
private const val LOGIN_RESULT_PATH =
"com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt"