Merge pull request #7243 from vector-im/feature/mna/device-manager-signout-session

[Device management] Sign out a session (PSG-742)
This commit is contained in:
Maxime NATUREL 2022-09-29 11:43:56 +02:00 committed by GitHub
commit 75a381ea0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1016 additions and 135 deletions

1
changelog.d/7190.wip Normal file
View file

@ -0,0 +1 @@
[Device management] Sign out a session

View file

@ -3290,6 +3290,7 @@
<string name="device_manager_other_sessions_no_unverified_sessions_found">No unverified sessions found.</string>
<string name="device_manager_other_sessions_no_inactive_sessions_found">No inactive sessions found.</string>
<string name="device_manager_other_sessions_clear_filter">Clear Filter</string>
<string name="device_manager_session_overview_signout">Sign out of this session</string>
<string name="device_manager_session_details_title">Session details</string>
<string name="device_manager_session_details_description">Application, device, and activity information.</string>
<string name="device_manager_session_details_session_name">Session name</string>

View file

@ -41,6 +41,10 @@
<item name="lineHeight">24sp</item>
</style>
<style name="Widget.Vector.Button.Text.Destructive">
<item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayDestructive</item>
</style>
<style name="Widget.Vector.Button.Text.OnPrimary">
<item name="colorControlHighlight">?colorOnPrimary</item>
<item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayOnPrimary</item>

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,4 +20,8 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class SessionOverviewAction : VectorViewModelAction {
object VerifySession : SessionOverviewAction()
object SignoutOtherSession : SessionOverviewAction()
object SsoAuthDone : SessionOverviewAction()
data class PasswordAuthDone(val password: String) : SessionOverviewAction()
object ReAuthCancelled : SessionOverviewAction()
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2.overview
import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
@ -27,16 +28,22 @@ import androidx.core.view.isVisible
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider
import im.vector.app.databinding.FragmentSessionOverviewBinding
import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState
import im.vector.app.features.workers.signout.SignOutUiWorker
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject
/**
@ -66,18 +73,7 @@ class SessionOverviewFragment :
observeViewEvents()
initSessionInfoView()
initVerifyButton()
}
private fun initSessionInfoView() {
views.sessionOverviewInfo.onLearnMoreClickListener = {
Toast.makeText(context, "Learn more verification status", Toast.LENGTH_LONG).show()
}
}
private fun initVerifyButton() {
views.sessionOverviewInfo.viewVerifyButton.debouncedClicks {
viewModel.handle(SessionOverviewAction.VerifySession)
}
initSignoutButton()
}
private fun observeViewEvents() {
@ -92,10 +88,60 @@ class SessionOverviewFragment :
is SessionOverviewViewEvent.PromptResetSecrets -> {
navigator.open4SSetup(requireActivity(), SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET)
}
is SessionOverviewViewEvent.RequestReAuth -> askForReAuthentication(it)
SessionOverviewViewEvent.SignoutSuccess -> viewNavigator.goBack(requireActivity())
is SessionOverviewViewEvent.SignoutError -> showFailure(it.error)
}
}
}
private fun initSessionInfoView() {
views.sessionOverviewInfo.onLearnMoreClickListener = {
Toast.makeText(context, "Learn more verification status", Toast.LENGTH_LONG).show()
}
}
private fun initVerifyButton() {
views.sessionOverviewInfo.viewVerifyButton.debouncedClicks {
viewModel.handle(SessionOverviewAction.VerifySession)
}
}
private fun initSignoutButton() {
views.sessionOverviewSignout.debouncedClicks {
confirmSignoutSession()
}
}
private fun confirmSignoutSession() = withState(viewModel) { state ->
if (state.deviceInfo.invoke()?.isCurrentDevice.orFalse()) {
confirmSignoutCurrentSession()
} else {
confirmSignoutOtherSession()
}
}
private fun confirmSignoutCurrentSession() {
activity?.let { SignOutUiWorker(it).perform() }
}
private fun confirmSignoutOtherSession() {
activity?.let {
MaterialAlertDialogBuilder(it)
.setTitle(R.string.action_sign_out)
.setMessage(R.string.action_sign_out_confirmation_simple)
.setPositiveButton(R.string.action_sign_out) { _, _ ->
signoutSession()
}
.setNegativeButton(R.string.action_cancel, null)
.show()
}
}
private fun signoutSession() {
viewModel.handle(SessionOverviewAction.SignoutOtherSession)
}
override fun onDestroyView() {
cleanUpSessionInfoView()
super.onDestroyView()
@ -122,16 +168,20 @@ class SessionOverviewFragment :
}
override fun invalidate() = withState(viewModel) { state ->
updateToolbar(state.isCurrentSession)
updateToolbar(state)
updateEntryDetails(state.deviceId)
updateSessionInfo(state)
updateLoading(state.isLoading)
}
private fun updateToolbar(isCurrentSession: Boolean) {
val titleResId = if (isCurrentSession) R.string.device_manager_current_session_title else R.string.device_manager_session_title
(activity as? AppCompatActivity)
?.supportActionBar
?.setTitle(titleResId)
private fun updateToolbar(viewState: SessionOverviewViewState) {
if (viewState.deviceInfo is Success) {
val titleResId =
if (viewState.deviceInfo.invoke().isCurrentDevice) R.string.device_manager_current_session_title else R.string.device_manager_session_title
(activity as? AppCompatActivity)
?.supportActionBar
?.setTitle(titleResId)
}
}
private fun updateEntryDetails(deviceId: String) {
@ -143,10 +193,11 @@ class SessionOverviewFragment :
private fun updateSessionInfo(viewState: SessionOverviewViewState) {
if (viewState.deviceInfo is Success) {
views.sessionOverviewInfo.isVisible = true
val isCurrentSession = viewState.isCurrentSession
val deviceInfo = viewState.deviceInfo.invoke()
val isCurrentSession = deviceInfo.isCurrentDevice
val infoViewState = SessionInfoViewState(
isCurrentSession = isCurrentSession,
deviceFullInfo = viewState.deviceInfo.invoke(),
deviceFullInfo = deviceInfo,
isVerifyButtonVisible = isCurrentSession || viewState.isCurrentSessionTrusted,
isDetailsButtonVisible = false,
isLearnMoreLinkVisible = true,
@ -157,4 +208,45 @@ class SessionOverviewFragment :
views.sessionOverviewInfo.isVisible = false
}
}
private fun updateLoading(isLoading: Boolean) {
if (isLoading) {
showLoading(null)
} else {
dismissLoadingDialog()
}
}
private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) {
LoginFlowTypes.SSO -> {
viewModel.handle(SessionOverviewAction.SsoAuthDone)
}
LoginFlowTypes.PASSWORD -> {
val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: ""
viewModel.handle(SessionOverviewAction.PasswordAuthDone(password))
}
else -> {
viewModel.handle(SessionOverviewAction.ReAuthCancelled)
}
}
} else {
viewModel.handle(SessionOverviewAction.ReAuthCancelled)
}
}
/**
* Launch the re auth activity to get credentials.
*/
private fun askForReAuthentication(reAuthReq: SessionOverviewViewEvent.RequestReAuth) {
ReAuthActivity.newIntent(
requireContext(),
reAuthReq.registrationFlowResponse,
reAuthReq.lastErrorCode,
getString(R.string.devices_delete_dialog_title)
).let { intent ->
reAuthActivityResultLauncher.launch(intent)
}
}
}

View file

@ -17,9 +17,17 @@
package im.vector.app.features.settings.devices.v2.overview
import im.vector.app.core.platform.VectorViewEvents
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
sealed class SessionOverviewViewEvent : VectorViewEvents {
object ShowVerifyCurrentSession : SessionOverviewViewEvent()
data class ShowVerifyOtherSession(val deviceId: String) : SessionOverviewViewEvent()
object PromptResetSecrets : SessionOverviewViewEvent()
data class RequestReAuth(
val registrationFlowResponse: RegistrationFlowResponse,
val lastErrorCode: String?
) : SessionOverviewViewEvent()
object SignoutSuccess : SessionOverviewViewEvent()
data class SignoutError(val error: Throwable) : SessionOverviewViewEvent()
}

View file

@ -21,26 +21,47 @@ import com.airbnb.mvrx.Success
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.settings.devices.v2.IsCurrentSessionUseCase
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import timber.log.Timber
import javax.net.ssl.HttpsURLConnection
import kotlin.coroutines.Continuation
class SessionOverviewViewModel @AssistedInject constructor(
@Assisted val initialState: SessionOverviewViewState,
private val activeSessionHolder: ActiveSessionHolder,
private val isCurrentSessionUseCase: IsCurrentSessionUseCase,
private val stringProvider: StringProvider,
private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase,
private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase,
) : VectorViewModel<SessionOverviewViewState, SessionOverviewAction, SessionOverviewViewEvent>(initialState) {
private val signoutSessionUseCase: SignoutSessionUseCase,
private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase,
private val pendingAuthHandler: PendingAuthHandler,
private val activeSessionHolder: ActiveSessionHolder,
refreshDevicesUseCase: RefreshDevicesUseCase,
) : VectorSessionsListViewModel<SessionOverviewViewState, SessionOverviewAction, SessionOverviewViewEvent>(
initialState, activeSessionHolder, refreshDevicesUseCase
) {
companion object : MavericksViewModelFactory<SessionOverviewViewModel, SessionOverviewViewState> by hiltMavericksViewModelFactory()
@ -50,17 +71,10 @@ class SessionOverviewViewModel @AssistedInject constructor(
}
init {
setState {
copy(isCurrentSession = isCurrentSession(deviceId))
}
observeSessionInfo(initialState.deviceId)
observeCurrentSessionInfo()
}
private fun isCurrentSession(deviceId: String): Boolean {
return isCurrentSessionUseCase.execute(deviceId)
}
private fun observeSessionInfo(deviceId: String) {
getDeviceFullInfoUseCase.execute(deviceId)
.onEach { setState { copy(deviceInfo = Success(it)) } }
@ -83,11 +97,15 @@ class SessionOverviewViewModel @AssistedInject constructor(
override fun handle(action: SessionOverviewAction) {
when (action) {
is SessionOverviewAction.VerifySession -> handleVerifySessionAction()
SessionOverviewAction.SignoutOtherSession -> handleSignoutOtherSession()
SessionOverviewAction.SsoAuthDone -> handleSsoAuthDone()
is SessionOverviewAction.PasswordAuthDone -> handlePasswordAuthDone(action)
SessionOverviewAction.ReAuthCancelled -> handleReAuthCancelled()
}
}
private fun handleVerifySessionAction() = withState { viewState ->
if (viewState.isCurrentSession) {
if (viewState.deviceInfo.invoke()?.isCurrentDevice.orFalse()) {
handleVerifyCurrentSession()
} else {
handleVerifyOtherSession(viewState.deviceId)
@ -108,4 +126,76 @@ class SessionOverviewViewModel @AssistedInject constructor(
private fun handleVerifyOtherSession(deviceId: String) {
_viewEvents.post(SessionOverviewViewEvent.ShowVerifyOtherSession(deviceId))
}
private fun handleSignoutOtherSession() = withState { state ->
// signout process for current session is not handled here
if (!state.deviceInfo.invoke()?.isCurrentDevice.orFalse()) {
handleSignoutOtherSession(state.deviceId)
}
}
private fun handleSignoutOtherSession(deviceId: String) {
viewModelScope.launch {
setLoading(true)
val signoutResult = signout(deviceId)
setLoading(false)
if (signoutResult.isSuccess) {
onSignoutSuccess()
} else {
when (val failure = signoutResult.exceptionOrNull()) {
null -> onSignoutSuccess()
else -> onSignoutFailure(failure)
}
}
}
}
private suspend fun signout(deviceId: String) = signoutSessionUseCase.execute(deviceId, object : UserInteractiveAuthInterceptor {
override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation<UIABaseAuth>) {
when (val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise)) {
is SignoutSessionResult.ReAuthNeeded -> onReAuthNeeded(result)
is SignoutSessionResult.Completed -> Unit
}
}
})
private fun onReAuthNeeded(reAuthNeeded: SignoutSessionResult.ReAuthNeeded) {
Timber.d("onReAuthNeeded")
pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation
_viewEvents.post(SessionOverviewViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode))
}
private fun setLoading(isLoading: Boolean) {
setState { copy(isLoading = isLoading) }
}
private fun onSignoutSuccess() {
Timber.d("signout success")
refreshDeviceList()
_viewEvents.post(SessionOverviewViewEvent.SignoutSuccess)
}
private fun onSignoutFailure(failure: Throwable) {
Timber.e("signout failure", failure)
val failureMessage = if (failure is Failure.OtherServerError && failure.httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED) {
stringProvider.getString(R.string.authentication_error)
} else {
stringProvider.getString(R.string.matrix_error)
}
_viewEvents.post(SessionOverviewViewEvent.SignoutError(Exception(failureMessage)))
}
private fun handleSsoAuthDone() {
pendingAuthHandler.ssoAuthDone()
}
private fun handlePasswordAuthDone(action: SessionOverviewAction.PasswordAuthDone) {
pendingAuthHandler.passwordAuthDone(action.password)
}
private fun handleReAuthCancelled() {
pendingAuthHandler.reAuthCancelled()
}
}

View file

@ -17,6 +17,7 @@
package im.vector.app.features.settings.devices.v2.overview
import android.content.Context
import androidx.fragment.app.FragmentActivity
import im.vector.app.features.settings.devices.v2.details.SessionDetailsActivity
import im.vector.app.features.settings.devices.v2.rename.RenameSessionActivity
import javax.inject.Inject
@ -30,4 +31,8 @@ class SessionOverviewViewNavigator @Inject constructor() {
fun goToRenameSession(context: Context, deviceId: String) {
context.startActivity(RenameSessionActivity.newIntent(context, deviceId))
}
fun goBack(fragmentActivity: FragmentActivity) {
fragmentActivity.finish()
}
}

View file

@ -23,9 +23,9 @@ import im.vector.app.features.settings.devices.v2.DeviceFullInfo
data class SessionOverviewViewState(
val deviceId: String,
val isCurrentSession: Boolean = false,
val isCurrentSessionTrusted: Boolean = false,
val deviceInfo: Async<DeviceFullInfo> = Uninitialized,
val isLoading: Boolean = false,
) : MavericksState {
constructor(args: SessionOverviewArgs) : this(
deviceId = args.deviceId

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings.devices.v2.signout
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.login.ReAuthHelper
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserPasswordAuth
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import javax.inject.Inject
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class InterceptSignoutFlowResponseUseCase @Inject constructor(
private val reAuthHelper: ReAuthHelper,
private val activeSessionHolder: ActiveSessionHolder,
) {
fun execute(
flowResponse: RegistrationFlowResponse,
errCode: String?,
promise: Continuation<UIABaseAuth>
): SignoutSessionResult {
return if (flowResponse.nextUncompletedStage() == LoginFlowTypes.PASSWORD && reAuthHelper.data != null && errCode == null) {
UserPasswordAuth(
session = null,
user = activeSessionHolder.getActiveSession().myUserId,
password = reAuthHelper.data
).let { promise.resume(it) }
SignoutSessionResult.Completed
} else {
SignoutSessionResult.ReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = flowResponse.session),
uiaContinuation = promise,
flowResponse = flowResponse,
errCode = errCode
)
}
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings.devices.v2.signout
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import kotlin.coroutines.Continuation
sealed class SignoutSessionResult {
data class ReAuthNeeded(
val pendingAuth: UIABaseAuth,
val uiaContinuation: Continuation<UIABaseAuth>,
val flowResponse: RegistrationFlowResponse,
val errCode: String?
) : SignoutSessionResult()
object Completed : SignoutSessionResult()
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings.devices.v2.signout
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.util.awaitCallback
import javax.inject.Inject
class SignoutSessionUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
suspend fun execute(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor): Result<Unit> {
return deleteDevice(deviceId, userInteractiveAuthInterceptor)
}
private suspend fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) = runCatching {
awaitCallback { matrixCallback ->
activeSessionHolder.getActiveSession()
.cryptoService()
.deleteDevice(deviceId, userInteractiveAuthInterceptor, matrixCallback)
}
}
}

View file

@ -31,6 +31,19 @@
app:sessionOverviewEntryDescription="@string/device_manager_session_details_description"
app:sessionOverviewEntryTitle="@string/device_manager_session_details_title" />
<Button
android:id="@+id/sessionOverviewSignout"
style="@style/Widget.Vector.Button.Text.Destructive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:text="@string/device_manager_session_overview_signout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sessionOverviewEntryDetails"
app:layout_constraintWidth="wrap_content" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -70,6 +70,7 @@ class DevicesViewModelTest {
@Before
fun setup() {
// Needed for internal usage of Flow<T>.throttleFirst() inside the ViewModel
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 1234
}
@ -217,8 +218,8 @@ class DevicesViewModelTest {
.fakeSession
.fakeCryptoService
.fakeVerificationService
every { fakeVerificationService.addListener(any()) } just runs
every { fakeVerificationService.removeListener(any()) } just runs
fakeVerificationService.givenAddListenerSucceeds()
fakeVerificationService.givenRemoveListenerSucceeds()
return fakeVerificationService
}

View file

@ -1,77 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings.devices.v2
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.verify
import org.amshove.kluent.shouldBe
import org.junit.Test
import org.matrix.android.sdk.api.auth.data.SessionParams
private const val A_SESSION_ID_1 = "session-id-1"
private const val A_SESSION_ID_2 = "session-id-2"
class IsCurrentSessionUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val isCurrentSessionUseCase = IsCurrentSessionUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
)
@Test
fun `given the session id of the current session when checking if id is current session then result is true`() {
// Given
val sessionParams = givenIdForCurrentSession(A_SESSION_ID_1)
// When
val result = isCurrentSessionUseCase.execute(A_SESSION_ID_1)
// Then
result shouldBe true
verify { sessionParams.deviceId }
}
@Test
fun `given a session id different from the current session id when checking if id is current session then result is false`() {
// Given
val sessionParams = givenIdForCurrentSession(A_SESSION_ID_1)
// When
val result = isCurrentSessionUseCase.execute(A_SESSION_ID_2)
// Then
result shouldBe false
verify { sessionParams.deviceId }
}
@Test
fun `given no current active session when checking if id is current session then result is false`() {
// Given
fakeActiveSessionHolder.givenGetSafeActiveSessionReturns(null)
// When
val result = isCurrentSessionUseCase.execute(A_SESSION_ID_1)
// Then
result shouldBe false
}
private fun givenIdForCurrentSession(sessionId: String): SessionParams {
return fakeActiveSessionHolder.fakeSession.givenSessionId(sessionId)
}
}

View file

@ -16,26 +16,53 @@
package im.vector.app.features.settings.devices.v2.overview
import android.os.SystemClock
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.R
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.IsCurrentSessionUseCase
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult
import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakePendingAuthHandler
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.flow.flowOf
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import javax.net.ssl.HttpsURLConnection
import kotlin.coroutines.Continuation
private const val A_SESSION_ID_1 = "session-id-1"
private const val A_SESSION_ID_2 = "session-id-2"
private const val AUTH_ERROR_MESSAGE = "auth-error-message"
private const val AN_ERROR_MESSAGE = "error-message"
private const val A_PASSWORD = "password"
class SessionOverviewViewModelTest {
@ -46,29 +73,48 @@ class SessionOverviewViewModelTest {
deviceId = A_SESSION_ID_1
)
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val isCurrentSessionUseCase = mockk<IsCurrentSessionUseCase>()
private val fakeStringProvider = FakeStringProvider()
private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>()
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk<CheckIfCurrentSessionCanBeVerifiedUseCase>()
private val signoutSessionUseCase = mockk<SignoutSessionUseCase>()
private val interceptSignoutFlowResponseUseCase = mockk<InterceptSignoutFlowResponseUseCase>()
private val fakePendingAuthHandler = FakePendingAuthHandler()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>()
private fun createViewModel() = SessionOverviewViewModel(
initialState = SessionOverviewViewState(args),
activeSessionHolder = fakeActiveSessionHolder.instance,
isCurrentSessionUseCase = isCurrentSessionUseCase,
stringProvider = fakeStringProvider.instance,
getDeviceFullInfoUseCase = getDeviceFullInfoUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
signoutSessionUseCase = signoutSessionUseCase,
interceptSignoutFlowResponseUseCase = interceptSignoutFlowResponseUseCase,
pendingAuthHandler = fakePendingAuthHandler.instance,
activeSessionHolder = fakeActiveSessionHolder.instance,
refreshDevicesUseCase = refreshDevicesUseCase,
)
@Before
fun setup() {
// Needed for internal usage of Flow<T>.throttleFirst() inside the ViewModel
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 1234
givenVerificationService()
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given the viewModel has been initialized then viewState is updated with session info and current session verification status`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val isCurrentSession = true
every { isCurrentSessionUseCase.execute(any()) } returns isCurrentSession
givenCurrentSessionIsTrusted()
val expectedState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSession = isCurrentSession,
deviceInfo = Success(deviceFullInfo),
isCurrentSessionTrusted = true,
)
@ -81,7 +127,6 @@ class SessionOverviewViewModelTest {
.assertLatestState { state -> state == expectedState }
.finish()
verify {
isCurrentSessionUseCase.execute(A_SESSION_ID_1)
getDeviceFullInfoUseCase.execute(A_SESSION_ID_1)
}
}
@ -90,8 +135,8 @@ class SessionOverviewViewModelTest {
fun `given current session can be verified when handling verify current session action then self verification event is posted`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns true
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
every { isCurrentSessionUseCase.execute(any()) } returns true
val verifySessionAction = SessionOverviewAction.VerifySession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns true
givenCurrentSessionIsTrusted()
@ -114,8 +159,8 @@ class SessionOverviewViewModelTest {
fun `given current session cannot be verified when handling verify current session action then reset secrets event is posted`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns true
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
every { isCurrentSessionUseCase.execute(any()) } returns true
val verifySessionAction = SessionOverviewAction.VerifySession
coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns false
givenCurrentSessionIsTrusted()
@ -138,8 +183,8 @@ class SessionOverviewViewModelTest {
fun `given another session when handling verify session action then verify session event is posted`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
every { isCurrentSessionUseCase.execute(any()) } returns false
val verifySessionAction = SessionOverviewAction.VerifySession
givenCurrentSessionIsTrusted()
@ -154,6 +199,248 @@ class SessionOverviewViewModelTest {
.finish()
}
@Test
fun `given another session and no reAuth is needed when handling signout action then signout process is performed`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
givenSignoutSuccess(A_SESSION_ID_1)
every { refreshDevicesUseCase.execute() } just runs
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSessionTrusted = true,
deviceInfo = Success(deviceFullInfo),
isLoading = false,
)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutSuccess }
.finish()
verify {
refreshDevicesUseCase.execute()
}
}
@Test
fun `given another session and server error during signout when handling signout action then signout process is performed`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val serverError = Failure.OtherServerError(errorBody = "", httpCode = HttpsURLConnection.HTTP_UNAUTHORIZED)
givenSignoutError(A_SESSION_ID_1, serverError)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSessionTrusted = true,
deviceInfo = Success(deviceFullInfo),
isLoading = false,
)
fakeStringProvider.given(R.string.authentication_error, AUTH_ERROR_MESSAGE)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AUTH_ERROR_MESSAGE }
.finish()
}
@Test
fun `given another session and unexpected error during signout when handling signout action then signout process is performed`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val error = Exception()
givenSignoutError(A_SESSION_ID_1, error)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSessionTrusted = true,
deviceInfo = Success(deviceFullInfo),
isLoading = false,
)
fakeStringProvider.given(R.string.matrix_error, AN_ERROR_MESSAGE)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AN_ERROR_MESSAGE }
.finish()
}
@Test
fun `given another session and reAuth is needed during signout when handling signout action then requestReAuth is sent and pending auth is stored`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val reAuthNeeded = givenSignoutReAuthNeeded(A_SESSION_ID_1)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session)
val expectedReAuthEvent = SessionOverviewViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertEvent { it == expectedReAuthEvent }
.finish()
fakePendingAuthHandler.instance.pendingAuth shouldBeEqualTo expectedPendingAuth
fakePendingAuthHandler.instance.uiaContinuation shouldBeEqualTo reAuthNeeded.uiaContinuation
}
@Test
fun `given SSO auth has been done when handling ssoAuthDone action then corresponding method of pending auth handler is called`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val action = SessionOverviewAction.SsoAuthDone
givenCurrentSessionIsTrusted()
every { fakePendingAuthHandler.instance.ssoAuthDone() } just runs
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.ssoAuthDone()
}
}
@Test
fun `given password auth has been done when handling passwordAuthDone action then corresponding method of pending auth handler is called`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val action = SessionOverviewAction.PasswordAuthDone(password = A_PASSWORD)
givenCurrentSessionIsTrusted()
every { fakePendingAuthHandler.instance.passwordAuthDone(any()) } just runs
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.passwordAuthDone(A_PASSWORD)
}
}
@Test
fun `given reAuth has been cancelled when handling reAuthCancelled action then corresponding method of pending auth handler is called`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val action = SessionOverviewAction.ReAuthCancelled
givenCurrentSessionIsTrusted()
every { fakePendingAuthHandler.instance.reAuthCancelled() } just runs
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest.finish()
verifyAll {
fakePendingAuthHandler.instance.reAuthCancelled()
}
}
private fun givenSignoutSuccess(deviceId: String) {
val interceptor = slot<UserInteractiveAuthInterceptor>()
val flowResponse = mockk<RegistrationFlowResponse>()
val errorCode = "errorCode"
val promise = mockk<Continuation<UIABaseAuth>>()
every { interceptSignoutFlowResponseUseCase.execute(flowResponse, errorCode, promise) } returns SignoutSessionResult.Completed
coEvery { signoutSessionUseCase.execute(deviceId, capture(interceptor)) } coAnswers {
secondArg<UserInteractiveAuthInterceptor>().performStage(flowResponse, errorCode, promise)
Result.success(Unit)
}
}
private fun givenSignoutReAuthNeeded(deviceId: String): SignoutSessionResult.ReAuthNeeded {
val interceptor = slot<UserInteractiveAuthInterceptor>()
val flowResponse = mockk<RegistrationFlowResponse>()
every { flowResponse.session } returns A_SESSION_ID_1
val errorCode = "errorCode"
val promise = mockk<Continuation<UIABaseAuth>>()
val reAuthNeeded = SignoutSessionResult.ReAuthNeeded(
pendingAuth = mockk(),
uiaContinuation = promise,
flowResponse = flowResponse,
errCode = errorCode,
)
every { interceptSignoutFlowResponseUseCase.execute(flowResponse, errorCode, promise) } returns reAuthNeeded
coEvery { signoutSessionUseCase.execute(deviceId, capture(interceptor)) } coAnswers {
secondArg<UserInteractiveAuthInterceptor>().performStage(flowResponse, errorCode, promise)
Result.success(Unit)
}
return reAuthNeeded
}
private fun givenSignoutError(deviceId: String, error: Throwable) {
coEvery { signoutSessionUseCase.execute(deviceId, any()) } returns Result.failure(error)
}
private fun givenVerificationService(): FakeVerificationService {
val fakeVerificationService = fakeActiveSessionHolder
.fakeSession
.fakeCryptoService
.fakeVerificationService
fakeVerificationService.givenAddListenerSucceeds()
fakeVerificationService.givenRemoveListenerSucceeds()
return fakeVerificationService
}
private fun givenCurrentSessionIsTrusted() {
fakeActiveSessionHolder.fakeSession.givenSessionId(A_SESSION_ID_2)
val deviceFullInfo = mockk<DeviceFullInfo>()

View file

@ -17,12 +17,15 @@
package im.vector.app.features.settings.devices.v2.overview
import android.content.Intent
import androidx.fragment.app.FragmentActivity
import im.vector.app.features.settings.devices.v2.details.SessionDetailsActivity
import im.vector.app.features.settings.devices.v2.rename.RenameSessionActivity
import im.vector.app.test.fakes.FakeContext
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
@ -77,6 +80,21 @@ class SessionOverviewViewNavigatorTest {
}
}
@Test
fun `given an activity when going back then the activity is finished`() {
// Given
val fragmentActivity = mockk<FragmentActivity>()
every { fragmentActivity.finish() } just runs
// When
sessionOverviewViewNavigator.goBack(fragmentActivity)
// Then
verify {
fragmentActivity.finish()
}
}
private fun givenIntentForSessionDetails(sessionId: String): Intent {
val intent = mockk<Intent>()
every { SessionDetailsActivity.newIntent(context.instance, sessionId) } returns intent

View file

@ -0,0 +1,174 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings.devices.v2.signout
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeReAuthHelper
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeInstanceOf
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserPasswordAuth
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
private const val A_PASSWORD = "password"
private const val A_SESSION_ID = "session-id"
private const val AN_ERROR_CODE = "error-code"
class InterceptSignoutFlowResponseUseCaseTest {
private val fakeReAuthHelper = FakeReAuthHelper()
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val interceptSignoutFlowResponseUseCase = InterceptSignoutFlowResponseUseCase(
reAuthHelper = fakeReAuthHelper.instance,
activeSessionHolder = fakeActiveSessionHolder.instance,
)
@Before
fun setUp() {
mockkStatic("org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponseKt")
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given no error and a stored password and a next stage as password when intercepting then promise is resumed and success is returned`() {
// Given
val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID)
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode: String? = null
val promise = mockk<Continuation<UIABaseAuth>>()
every { promise.resume(any()) } just runs
val expectedAuth = UserPasswordAuth(
session = null,
user = fakeActiveSessionHolder.fakeSession.myUserId,
password = A_PASSWORD,
)
// When
val result = interceptSignoutFlowResponseUseCase.execute(
flowResponse = registrationFlowResponse,
errCode = errorCode,
promise = promise,
)
// Then
result shouldBeInstanceOf (SignoutSessionResult.Completed::class)
every {
promise.resume(expectedAuth)
}
}
@Test
fun `given an error when intercepting then reAuthNeeded result is returned`() {
// Given
val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID)
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode = AN_ERROR_CODE
val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
errCode = errorCode
)
// When
val result = interceptSignoutFlowResponseUseCase.execute(
flowResponse = registrationFlowResponse,
errCode = errorCode,
promise = promise,
)
// Then
result shouldBeEqualTo expectedResult
}
@Test
fun `given next stage is not password when intercepting then reAuthNeeded result is returned`() {
// Given
val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.SSO, A_SESSION_ID)
fakeReAuthHelper.givenStoredPassword(A_PASSWORD)
val errorCode: String? = null
val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
errCode = errorCode
)
// When
val result = interceptSignoutFlowResponseUseCase.execute(
flowResponse = registrationFlowResponse,
errCode = errorCode,
promise = promise,
)
// Then
result shouldBeEqualTo expectedResult
}
@Test
fun `given no existing stored password when intercepting then reAuthNeeded result is returned`() {
// Given
val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID)
fakeReAuthHelper.givenStoredPassword(null)
val errorCode: String? = null
val promise = mockk<Continuation<UIABaseAuth>>()
val expectedResult = SignoutSessionResult.ReAuthNeeded(
pendingAuth = DefaultBaseAuth(session = A_SESSION_ID),
uiaContinuation = promise,
flowResponse = registrationFlowResponse,
errCode = errorCode
)
// When
val result = interceptSignoutFlowResponseUseCase.execute(
flowResponse = registrationFlowResponse,
errCode = errorCode,
promise = promise,
)
// Then
result shouldBeEqualTo expectedResult
}
private fun givenNextUncompletedStage(nextStage: String, sessionId: String): RegistrationFlowResponse {
val registrationFlowResponse = mockk<RegistrationFlowResponse>()
every { registrationFlowResponse.nextUncompletedStage() } returns nextStage
every { registrationFlowResponse.session } returns sessionId
return registrationFlowResponse
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.settings.devices.v2.signout
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe
import org.junit.Test
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
private const val A_DEVICE_ID = "device-id"
class SignoutSessionUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val signoutSessionUseCase = SignoutSessionUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance
)
@Test
fun `given a device id when signing out with success then success result is returned`() = runTest {
// Given
val interceptor = givenAuthInterceptor()
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.givenDeleteDeviceSucceeds(A_DEVICE_ID)
// When
val result = signoutSessionUseCase.execute(A_DEVICE_ID, interceptor)
// Then
result.isSuccess shouldBe true
every {
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.deleteDevice(A_DEVICE_ID, interceptor, any())
}
}
@Test
fun `given a device id when signing out with error then failure result is returned`() = runTest {
// Given
val interceptor = givenAuthInterceptor()
val error = mockk<Exception>()
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.givenDeleteDeviceFailsWithError(A_DEVICE_ID, error)
// When
val result = signoutSessionUseCase.execute(A_DEVICE_ID, interceptor)
// Then
result.isFailure shouldBe true
every {
fakeActiveSessionHolder.fakeSession
.fakeCryptoService
.deleteDevice(A_DEVICE_ID, interceptor, any())
}
}
private fun givenAuthInterceptor() = mockk<UserInteractiveAuthInterceptor>()
}

View file

@ -67,4 +67,18 @@ class FakeCryptoService(
thirdArg<MatrixCallback<Unit>>().onFailure(error)
}
}
fun givenDeleteDeviceSucceeds(deviceId: String) {
val matrixCallback = slot<MatrixCallback<Unit>>()
every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers {
thirdArg<MatrixCallback<Unit>>().onSuccess(Unit)
}
}
fun givenDeleteDeviceFailsWithError(deviceId: String, error: Exception) {
val matrixCallback = slot<MatrixCallback<Unit>>()
every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers {
thirdArg<MatrixCallback<Unit>>().onFailure(error)
}
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import im.vector.app.features.auth.PendingAuthHandler
import io.mockk.mockk
import io.mockk.spyk
class FakePendingAuthHandler {
val instance = spyk(mockk<PendingAuthHandler>())
}

View file

@ -14,17 +14,17 @@
* limitations under the License.
*/
package im.vector.app.features.settings.devices.v2
package im.vector.app.test.fakes
import im.vector.app.core.di.ActiveSessionHolder
import javax.inject.Inject
import im.vector.app.features.login.ReAuthHelper
import io.mockk.every
import io.mockk.mockk
class IsCurrentSessionUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
class FakeReAuthHelper {
fun execute(deviceId: String): Boolean {
val currentDeviceId = activeSessionHolder.getSafeActiveSession()?.sessionParams?.deviceId.orEmpty()
return deviceId.isNotEmpty() && deviceId == currentDeviceId
val instance = mockk<ReAuthHelper>()
fun givenStoredPassword(pwd: String?) {
every { instance.data } returns pwd
}
}

View file

@ -16,7 +16,19 @@
package im.vector.app.test.fakes
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import org.matrix.android.sdk.api.session.crypto.verification.VerificationService
class FakeVerificationService : VerificationService by mockk()
class FakeVerificationService : VerificationService by mockk() {
fun givenAddListenerSucceeds() {
every { addListener(any()) } just runs
}
fun givenRemoveListenerSucceeds() {
every { removeListener(any()) } just runs
}
}