mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1517: Add check for claimed organization domain to SSO ViewModel (#816)
This commit is contained in:
parent
5ce45a8069
commit
0e9241d54c
14 changed files with 560 additions and 91 deletions
|
@ -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>
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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?,
|
||||
)
|
|
@ -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>
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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"),
|
||||
)
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Reference in a new issue