BIT-1517: Add check for claimed organization domain to SSO ViewModel (#816)

This commit is contained in:
Sean Weiser 2024-01-27 16:09:30 -06:00 committed by Álison Fernandes
parent 5ce45a8069
commit 0e9241d54c
14 changed files with 560 additions and 91 deletions

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Defines raw calls under the /organizations API.
*/
interface OrganizationApi {
/**
* Checks for the claimed domain organization of an email for SSO purposes.
*/
@POST("/organizations/domain/sso/details")
suspend fun getClaimedDomainOrganizationDetails(
@Body body: OrganizationDomainSsoDetailsRequestJson,
): Result<OrganizationDomainSsoDetailsResponseJson>
}

View file

@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import dagger.Module
import dagger.Provides
@ -84,4 +86,12 @@ object AuthNetworkModule {
): NewAuthRequestService = NewAuthRequestServiceImpl(
authRequestsApi = retrofits.unauthenticatedApiRetrofit.create(),
)
@Provides
@Singleton
fun providesOrganizationService(
retrofits: Retrofits,
): OrganizationService = OrganizationServiceImpl(
organizationApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body object when retrieving organization domain SSO info.
*
* @param email The email address to check against.
*/
@Serializable
data class OrganizationDomainSsoDetailsRequestJson(
@SerialName("email") val email: String,
)

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* Response object returned when requesting organization domain SSO details.
*
* @property isSsoAvailable Whether or not SSO is available for this domain.
* @property domainName The organization's domain name.
* @property organizationIdentifier The organization's identifier.
* @property isSsoRequired Whether or not SSO is required.
* @property verifiedDate The date these details were verified.
*/
@Serializable
data class OrganizationDomainSsoDetailsResponseJson(
@SerialName("ssoAvailable") val isSsoAvailable: Boolean,
@SerialName("domainName") val domainName: String,
@SerialName("organizationIdentifier") val organizationIdentifier: String,
@SerialName("ssoRequired") val isSsoRequired: Boolean,
@Contextual
@SerialName("verifiedDate") val verifiedDate: ZonedDateTime?,
)

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
/**
* Provides an API for querying organization endpoints.
*/
interface OrganizationService {
/**
* Request claimed organization domain information for an [email] needed for SSO requests.
*/
suspend fun getOrganizationDomainSsoDetails(
email: String,
): Result<OrganizationDomainSsoDetailsResponseJson>
}

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.OrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
/**
* Default implementation of [OrganizationService].
*/
class OrganizationServiceImpl constructor(
private val organizationApi: OrganizationApi,
) : OrganizationService {
override suspend fun getOrganizationDomainSsoDetails(
email: String,
): Result<OrganizationDomainSsoDetailsResponseJson> = organizationApi
.getClaimedDomainOrganizationDetails(
body = OrganizationDomainSsoDetailsRequestJson(
email = email,
),
)
}

View file

@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
@ -161,6 +162,13 @@ interface AuthRepository : AuthenticatorProvider {
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
/**
* Checks for a claimed domain organization for the [email] that can be used for an SSO request.
*/
suspend fun getOrganizationDomainSsoDetails(
email: String,
): OrganizationDomainSsoDetailsResult
/**
* Prevalidates the organization identifier used in an SSO request.
*/

View file

@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
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.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
@ -33,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
@ -83,6 +85,7 @@ class AuthRepositoryImpl(
private val haveIBeenPwnedService: HaveIBeenPwnedService,
private val identityService: IdentityService,
private val newAuthRequestService: NewAuthRequestService,
private val organizationService: OrganizationService,
private val authSdkSource: AuthSdkSource,
private val authDiskSource: AuthDiskSource,
private val environmentRepository: EnvironmentRepository,
@ -572,6 +575,22 @@ class AuthRepositoryImpl(
mutableCaptchaTokenFlow.tryEmit(tokenResult)
}
override suspend fun getOrganizationDomainSsoDetails(
email: String,
): OrganizationDomainSsoDetailsResult = organizationService
.getOrganizationDomainSsoDetails(
email = email,
)
.fold(
onSuccess = {
OrganizationDomainSsoDetailsResult.Success(
isSsoAvailable = it.isSsoAvailable,
organizationIdentifier = it.organizationIdentifier,
)
},
onFailure = { OrganizationDomainSsoDetailsResult.Failure },
)
override suspend fun prevalidateSso(
organizationIdentifier: String,
): PrevalidateSsoResult = identityService

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
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.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@ -38,6 +39,7 @@ object AuthRepositoryModule {
identityService: IdentityService,
haveIBeenPwnedService: HaveIBeenPwnedService,
newAuthRequestService: NewAuthRequestService,
organizationService: OrganizationService,
authSdkSource: AuthSdkSource,
authDiskSource: AuthDiskSource,
dispatchers: DispatcherManager,
@ -51,6 +53,7 @@ object AuthRepositoryModule {
devicesService = devicesService,
identityService = identityService,
newAuthRequestService = newAuthRequestService,
organizationService = organizationService,
authSdkSource = authSdkSource,
authDiskSource = authDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Response types when checking for an email's claimed domain organization.
*/
sealed class OrganizationDomainSsoDetailsResult {
/**
* The request was successful.
*
* @property isSsoAvailable Indicates if SSO is available for the email address.
* @property organizationIdentifier The claimed organization identifier for the email address.
*/
data class Success(
val isSsoAvailable: Boolean,
val organizationIdentifier: String,
) : OrganizationDomainSsoDetailsResult()
/**
* The request failed.
*/
data object Failure : OrganizationDomainSsoDetailsResult()
}

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SSO_URI
@ -49,7 +50,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
initialState = savedStateHandle[KEY_STATE]
?: EnterpriseSignOnState(
dialogState = null,
orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "",
orgIdentifierInput = "",
captchaToken = null,
),
) {
@ -86,6 +87,8 @@ class EnterpriseSignOnViewModel @Inject constructor(
)
}
.launchIn(viewModelScope)
checkOrganizationDomainSsoDetails()
}
override fun handleAction(action: EnterpriseSignOnAction) {
@ -105,6 +108,10 @@ class EnterpriseSignOnViewModel @Inject constructor(
handleOnSsoPrevalidationFailure()
}
is EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive -> {
handleOnOrganizationDomainSsoDetailsReceive(action)
}
is EnterpriseSignOnAction.Internal.OnSsoCallbackResult -> {
handleOnSsoCallbackResult(action)
}
@ -128,49 +135,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
}
private fun handleLogInClicked() {
if (!networkConnectionManager.isNetworkConnected) {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
message = R.string.internet_connection_required_message.asText(),
),
)
}
return
}
val organizationIdentifier = state.orgIdentifierInput
if (organizationIdentifier.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.validation_field_required.asText(
R.string.org_identifier.asText(),
),
),
)
}
return
}
showLoading()
viewModelScope.launch {
val prevalidateSsoResult = authRepository.prevalidateSso(organizationIdentifier)
when (prevalidateSsoResult) {
is PrevalidateSsoResult.Failure -> {
sendAction(EnterpriseSignOnAction.Internal.OnSsoPrevalidationFailure)
}
is PrevalidateSsoResult.Success -> {
prepareAndLaunchCustomTab(
organizationIdentifier = organizationIdentifier,
prevalidateSsoResult = prevalidateSsoResult,
)
}
}
}
prevalidateSso()
}
private fun handleOnLoginResult(action: EnterpriseSignOnAction.Internal.OnLoginResult) {
@ -222,6 +187,61 @@ class EnterpriseSignOnViewModel @Inject constructor(
showDefaultError()
}
private fun handleOnOrganizationDomainSsoDetailsFailure() {
mutableStateFlow.update {
it.copy(
dialogState = null,
orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "",
)
}
}
private fun handleOnOrganizationDomainSsoDetailsReceive(
action: EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive,
) {
when (val orgDetails = action.organizationDomainSsoDetailsResult) {
is OrganizationDomainSsoDetailsResult.Failure -> {
handleOnOrganizationDomainSsoDetailsFailure()
}
is OrganizationDomainSsoDetailsResult.Success -> {
handleOnOrganizationDomainSsoDetailsSuccess(orgDetails)
}
}
}
private fun handleOnOrganizationDomainSsoDetailsSuccess(
orgDetails: OrganizationDomainSsoDetailsResult.Success,
) {
if (!orgDetails.isSsoAvailable) {
mutableStateFlow.update {
it.copy(
dialogState = null,
orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "",
)
}
return
}
if (orgDetails.organizationIdentifier.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.organization_sso_identifier_required.asText(),
),
orgIdentifierInput = authRepository.rememberedOrgIdentifier ?: "",
)
}
return
}
mutableStateFlow.update { it.copy(orgIdentifierInput = orgDetails.organizationIdentifier) }
// If the email address is associated with a claimed organization we can proceed to the
// prevalidation step.
prevalidateSso()
}
private fun handleOrgIdentifierInputChanged(
action: EnterpriseSignOnAction.OrgIdentifierInputChange,
) {
@ -259,6 +279,52 @@ class EnterpriseSignOnViewModel @Inject constructor(
}
}
private fun prevalidateSso() {
if (!networkConnectionManager.isNetworkConnected) {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
message = R.string.internet_connection_required_message.asText(),
),
)
}
return
}
val organizationIdentifier = state.orgIdentifierInput
if (organizationIdentifier.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.validation_field_required.asText(
R.string.org_identifier.asText(),
),
),
)
}
return
}
showLoading()
viewModelScope.launch {
val prevalidateSsoResult = authRepository.prevalidateSso(organizationIdentifier)
when (prevalidateSsoResult) {
is PrevalidateSsoResult.Failure -> {
sendAction(EnterpriseSignOnAction.Internal.OnSsoPrevalidationFailure)
}
is PrevalidateSsoResult.Success -> {
prepareAndLaunchCustomTab(
organizationIdentifier = organizationIdentifier,
prevalidateSsoResult = prevalidateSsoResult,
)
}
}
}
}
private fun attemptLogin() {
val ssoCallbackResult = requireNotNull(savedSsoCallbackResult)
val ssoData = requireNotNull(ssoResponseData)
@ -267,6 +333,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
is SsoCallbackResult.MissingCode -> {
showDefaultError()
}
is SsoCallbackResult.Success -> {
if (ssoCallbackResult.state == ssoData.state) {
showLoading()
@ -288,6 +355,22 @@ class EnterpriseSignOnViewModel @Inject constructor(
}
}
private fun checkOrganizationDomainSsoDetails() {
mutableStateFlow.update {
it.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(R.string.loading.asText()),
)
}
viewModelScope.launch {
val result = authRepository.getOrganizationDomainSsoDetails(
email = EnterpriseSignOnArgs(savedStateHandle).emailAddress,
)
sendAction(
EnterpriseSignOnAction.Internal.OnOrganizationDomainSsoDetailsReceive(result),
)
}
}
private suspend fun prepareAndLaunchCustomTab(
organizationIdentifier: String,
prevalidateSsoResult: PrevalidateSsoResult.Success,
@ -446,6 +529,13 @@ sealed class EnterpriseSignOnAction {
*/
data object OnSsoPrevalidationFailure : Internal()
/**
* A result was received when requesting an [OrganizationDomainSsoDetailsResult].
*/
data class OnOrganizationDomainSsoDetailsReceive(
val organizationDomainSsoDetailsResult: OrganizationDomainSsoDetailsResult,
) : Internal()
/**
* A captcha callback result has been received
*/

View file

@ -0,0 +1,59 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.OrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
import java.time.ZonedDateTime
class OrganizationServiceTest : BaseServiceTest() {
private val organizationApi: OrganizationApi = retrofit.create()
private val organizationService = OrganizationServiceImpl(
organizationApi = organizationApi,
)
@Suppress("MaxLineLength")
@Test
fun `getOrganizationDomainSsoDetails when response is success should return PrevalidateSsoResponseJson`() =
runTest {
val email = "test@gmail.com"
server.enqueue(
MockResponse().setResponseCode(200).setBody(ORGANIZATION_DOMAIN_SSO_DETAILS_JSON),
)
val result = organizationService.getOrganizationDomainSsoDetails(email)
assertEquals(Result.success(ORGANIZATION_DOMAIN_SSO_BODY), result)
}
@Test
fun `getOrganizationDomainSsoDetails when response is an error should return an error`() =
runTest {
val email = "test@gmail.com"
server.enqueue(MockResponse().setResponseCode(400))
val result = organizationService.getOrganizationDomainSsoDetails(email)
assertTrue(result.isFailure)
}
}
private const val ORGANIZATION_DOMAIN_SSO_DETAILS_JSON = """
{
"ssoAvailable": true,
"domainName": "bitwarden.com",
"organizationIdentifier": "Test Org",
"ssoRequired": false,
"verifiedDate": "2024-09-13T00:00:00.000Z"
}
"""
private val ORGANIZATION_DOMAIN_SSO_BODY = OrganizationDomainSsoDetailsResponseJson(
isSsoAvailable = true,
organizationIdentifier = "Test Org",
domainName = "bitwarden.com",
isSsoRequired = false,
verifiedDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
)

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsRespon
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
@ -29,6 +30,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
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.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1
@ -44,6 +46,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
@ -100,6 +103,7 @@ class AuthRepositoryTest {
private val identityService: IdentityService = mockk()
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
private val newAuthRequestService: NewAuthRequestService = mockk()
private val organizationService: OrganizationService = mockk()
private val mutableVaultStateFlow = MutableStateFlow(VAULT_STATE)
private val vaultRepository: VaultRepository = mockk {
every { vaultStateFlow } returns mutableVaultStateFlow
@ -167,6 +171,7 @@ class AuthRepositoryTest {
identityService = identityService,
haveIBeenPwnedService = haveIBeenPwnedService,
newAuthRequestService = newAuthRequestService,
organizationService = organizationService,
authSdkSource = authSdkSource,
authDiskSource = fakeAuthDiskSource,
environmentRepository = fakeEnvironmentRepository,
@ -1853,6 +1858,41 @@ class AuthRepositoryTest {
}
}
@Test
fun `getOrganizationDomainSsoDetails Failure should return Failure `() = runTest {
val email = "test@gmail.com"
val throwable = Throwable()
coEvery {
organizationService.getOrganizationDomainSsoDetails(email)
} returns Result.failure(throwable)
val result = repository.getOrganizationDomainSsoDetails(email)
assertEquals(OrganizationDomainSsoDetailsResult.Failure, result)
}
@Test
fun `getOrganizationDomainSsoDetails Success should return Success`() = runTest {
val email = "test@gmail.com"
coEvery {
organizationService.getOrganizationDomainSsoDetails(email)
} returns Result.success(
OrganizationDomainSsoDetailsResponseJson(
isSsoAvailable = true,
organizationIdentifier = "Test Org",
domainName = "bitwarden.com",
isSsoRequired = false,
verifiedDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
),
)
val result = repository.getOrganizationDomainSsoDetails(email)
assertEquals(
OrganizationDomainSsoDetailsResult.Success(
isSsoAvailable = true,
organizationIdentifier = "Test Org",
),
result,
)
}
@Test
fun `prevalidateSso Failure should return Failure `() = runTest {
val organizationId = "organizationid"

View file

@ -7,6 +7,7 @@ import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.OrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
@ -20,6 +21,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.awaits
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@ -34,6 +36,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow<SsoCallbackResult>()
@ -43,6 +46,9 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
every { ssoCallbackResultFlow } returns mutableSsoCallbackResultFlow
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
every { rememberedOrgIdentifier } returns null
coEvery {
getOrganizationDomainSsoDetails(any())
} just awaits
}
private val environmentRepository: EnvironmentRepository = FakeEnvironmentRepository()
@ -198,7 +204,35 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
@Test
fun `LogInClick with no Internet should show error dialog`() = runTest {
val viewModel = createViewModel(isNetworkConnected = false)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
title = R.string.internet_connection_required_title.asText(),
message = R.string.internet_connection_required_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `OrgIdentifierInputChange should update organization identifier`() = runTest {
val input = "input"
val viewModel = createViewModel()
viewModel.actionChannel.trySend(EnterpriseSignOnAction.OrgIdentifierInputChange(input))
assertEquals(
DEFAULT_STATE.copy(orgIdentifierInput = input),
viewModel.stateFlow.value,
)
}
@Test
fun `DialogDismiss should clear the active dialog when DialogState is Error`() = runTest {
val viewModel = createViewModel(isNetworkConnected = false)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.actionChannel.trySend(EnterpriseSignOnAction.LogInClick)
assertEquals(
DEFAULT_STATE.copy(
@ -207,62 +241,33 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
message = R.string.internet_connection_required_message.asText(),
),
),
viewModel.stateFlow.value,
awaitItem(),
)
}
}
@Test
fun `OrgIdentifierInputChange should update organization identifier`() = runTest {
val input = "input"
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(EnterpriseSignOnAction.OrgIdentifierInputChange(input))
viewModel.actionChannel.trySend(EnterpriseSignOnAction.DialogDismiss)
assertEquals(
DEFAULT_STATE.copy(orgIdentifierInput = input),
viewModel.stateFlow.value,
DEFAULT_STATE,
awaitItem(),
)
}
}
@Test
fun `DialogDismiss should clear the active dialog when DialogState is Error`() {
val initialState = DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = "Error".asText(),
),
)
val viewModel = createViewModel(initialState)
assertEquals(
initialState,
viewModel.stateFlow.value,
)
viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss)
assertEquals(
initialState.copy(dialogState = null),
viewModel.stateFlow.value,
)
}
@Test
fun `DialogDismiss should clear the active dialog when DialogState is Loading`() {
val initialState = DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(
message = "Loading".asText(),
),
val viewModel = createViewModel(
dismissInitialDialog = false,
)
val viewModel = createViewModel(initialState)
assertEquals(
initialState,
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Loading(R.string.loading.asText()),
),
viewModel.stateFlow.value,
)
viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss)
assertEquals(
initialState.copy(dialogState = null),
DEFAULT_STATE.copy(dialogState = null),
viewModel.stateFlow.value,
)
}
@ -310,7 +315,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(
ssoData = DEFAULT_SSO_DATA,
emailAddress = DEFAULT_EMAIL,
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
@ -368,7 +372,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(
initialState = initialState,
ssoData = DEFAULT_SSO_DATA,
emailAddress = DEFAULT_EMAIL,
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
@ -426,7 +429,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(
initialState = initialState,
ssoData = DEFAULT_SSO_DATA,
emailAddress = DEFAULT_EMAIL,
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
@ -481,7 +483,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(
initialState = initialState,
ssoData = DEFAULT_SSO_DATA,
emailAddress = DEFAULT_EMAIL,
)
val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn")
@ -557,7 +558,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
val initialState = DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden")
val viewModel = createViewModel(
initialState = initialState,
emailAddress = "test@gmail.com",
ssoData = DEFAULT_SSO_DATA,
ssoCallbackResult = SsoCallbackResult.Success(
state = "abc",
@ -589,21 +589,138 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `OrganizationDomainSsoDetails failure should make a request, hide the dialog, and update the org input based on the remembered org`() = runTest {
coEvery {
authRepository.getOrganizationDomainSsoDetails(any())
} returns OrganizationDomainSsoDetailsResult.Failure
coEvery {
authRepository.rememberedOrgIdentifier
} returns "Bitwarden"
val viewModel = createViewModel(dismissInitialDialog = false)
assertEquals(
DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden"),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) {
authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL)
authRepository.rememberedOrgIdentifier
}
}
@Suppress("MaxLineLength")
@Test
fun `OrganizationDomainSsoDetails success with no SSO available should make a request, hide the dialog, and update the org input based on the remembered org`() = runTest {
val orgDetails = OrganizationDomainSsoDetailsResult.Success(
isSsoAvailable = false,
organizationIdentifier = "Bitwarden without SSO",
)
coEvery {
authRepository.getOrganizationDomainSsoDetails(any())
} returns orgDetails
coEvery {
authRepository.rememberedOrgIdentifier
} returns "Bitwarden"
val viewModel = createViewModel(dismissInitialDialog = false)
assertEquals(
DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden"),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) {
authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL)
authRepository.rememberedOrgIdentifier
}
}
@Suppress("MaxLineLength")
@Test
fun `OrganizationDomainSsoDetails success with blank identifier should make a request, show the error dialog, and update the org input based on the remembered org`() = runTest {
val orgDetails = OrganizationDomainSsoDetailsResult.Success(
isSsoAvailable = true,
organizationIdentifier = "",
)
coEvery {
authRepository.getOrganizationDomainSsoDetails(any())
} returns orgDetails
coEvery {
authRepository.rememberedOrgIdentifier
} returns "Bitwarden"
val viewModel = createViewModel(dismissInitialDialog = false)
assertEquals(
DEFAULT_STATE.copy(
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.organization_sso_identifier_required.asText(),
),
orgIdentifierInput = "Bitwarden",
),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) {
authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL)
authRepository.rememberedOrgIdentifier
}
}
@Suppress("MaxLineLength")
@Test
fun `OrganizationDomainSsoDetails success with valid organization should make a request then attempt to login`() = runTest {
val orgDetails = OrganizationDomainSsoDetailsResult.Success(
isSsoAvailable = true,
organizationIdentifier = "Bitwarden with SSO",
)
coEvery {
authRepository.getOrganizationDomainSsoDetails(any())
} returns orgDetails
// Just hang on this request; login is tested elsewhere
coEvery {
authRepository.prevalidateSso(any())
} just awaits
val viewModel = createViewModel(dismissInitialDialog = false)
assertEquals(
DEFAULT_STATE.copy(
orgIdentifierInput = "Bitwarden with SSO",
dialogState = EnterpriseSignOnState.DialogState.Loading(
message = R.string.logging_in.asText(),
),
),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) {
authRepository.getOrganizationDomainSsoDetails(DEFAULT_EMAIL)
}
}
@Suppress("LongParameterList")
private fun createViewModel(
initialState: EnterpriseSignOnState? = null,
emailAddress: String? = null,
ssoData: SsoResponseData? = null,
ssoCallbackResult: SsoCallbackResult? = null,
savedStateHandle: SavedStateHandle = SavedStateHandle(
initialState = mapOf(
"state" to initialState,
"email_address" to emailAddress,
"email_address" to DEFAULT_EMAIL,
"ssoData" to ssoData,
"ssoCallbackResult" to ssoCallbackResult,
),
),
isNetworkConnected: Boolean = true,
dismissInitialDialog: Boolean = true,
): EnterpriseSignOnViewModel = EnterpriseSignOnViewModel(
authRepository = authRepository,
environmentRepository = environmentRepository,
@ -611,6 +728,13 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
networkConnectionManager = FakeNetworkConnectionManager(isNetworkConnected),
savedStateHandle = savedStateHandle,
)
.also {
if (dismissInitialDialog) {
// A loading dialog is shown on initialization, so allow tests to automatically
// dismiss it.
it.trySendAction(EnterpriseSignOnAction.DialogDismiss)
}
}
companion object {
private val DEFAULT_STATE = EnterpriseSignOnState(