diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index abe0e2719f..fe4d0dc838 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt @@ -113,7 +113,6 @@ class DevicesViewModel @AssistedInject constructor( } } - // TODO update unit tests override fun handle(action: DevicesAction) { when (action) { is DevicesAction.PasswordAuthDone -> handlePasswordAuthDone(action) diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index bf06dd7329..71e9d609b7 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -19,16 +19,17 @@ package im.vector.app.features.settings.devices.v2 import android.os.SystemClock import com.airbnb.mvrx.Success import com.airbnb.mvrx.test.MavericksTestRule +import im.vector.app.R import im.vector.app.core.session.clientinfo.MatrixClientInfoContent import im.vector.app.features.settings.devices.v2.details.extended.DeviceExtendedInfo import im.vector.app.features.settings.devices.v2.list.DeviceType import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase -import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler +import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeStringProvider import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.test @@ -36,20 +37,32 @@ 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.justRun import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.runs 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.failure.Failure import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel +import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth +import javax.net.ssl.HttpsURLConnection + +private const val A_CURRENT_DEVICE_ID = "current-device-id" +private const val A_DEVICE_ID_1 = "device-id-1" +private const val A_DEVICE_ID_2 = "device-id-2" +private const val A_PASSWORD = "password" +private const val AUTH_ERROR_MESSAGE = "auth-error-message" +private const val AN_ERROR_MESSAGE = "error-message" class DevicesViewModelTest { @@ -60,9 +73,9 @@ class DevicesViewModelTest { private val fakeStringProvider = FakeStringProvider() private val getCurrentSessionCrossSigningInfoUseCase = mockk() private val getDeviceFullInfoListUseCase = mockk() - private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk() + private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk(relaxed = true) private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk() - private val fakeSignoutSessionsUseCase = mockk() + private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() private val fakeInterceptSignoutFlowResponseUseCase = mockk() private val fakePendingAuthHandler = FakePendingAuthHandler() private val refreshDevicesUseCase = mockk(relaxUnitFun = true) @@ -76,7 +89,7 @@ class DevicesViewModelTest { getDeviceFullInfoListUseCase = getDeviceFullInfoListUseCase, refreshDevicesOnCryptoDevicesChangeUseCase = refreshDevicesOnCryptoDevicesChangeUseCase, checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase, - signoutSessionsUseCase = fakeSignoutSessionsUseCase, + signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance, interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase, pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = refreshDevicesUseCase, @@ -88,6 +101,20 @@ class DevicesViewModelTest { // Needed for internal usage of Flow.throttleFirst() inside the ViewModel mockkStatic(SystemClock::class) every { SystemClock.elapsedRealtime() } returns 1234 + + givenVerificationService() + givenCurrentSessionCrossSigningInfo() + givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) + } + + private fun givenVerificationService(): FakeVerificationService { + val fakeVerificationService = fakeActiveSessionHolder + .fakeSession + .fakeCryptoService + .fakeVerificationService + fakeVerificationService.givenAddListenerSucceeds() + fakeVerificationService.givenRemoveListenerSucceeds() + return fakeVerificationService } @After @@ -99,9 +126,6 @@ class DevicesViewModelTest { fun `given the viewModel when initializing it then verification listener is added`() { // Given val fakeVerificationService = givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When val viewModel = createViewModel() @@ -116,9 +140,6 @@ class DevicesViewModelTest { fun `given the viewModel when clearing it then verification listener is removed`() { // Given val fakeVerificationService = givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When val viewModel = createViewModel() @@ -133,10 +154,7 @@ class DevicesViewModelTest { @Test fun `given the viewModel when initializing it then view state is updated with current session cross signing info`() { // Given - givenVerificationService() val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When val viewModelTest = createViewModel().test() @@ -149,10 +167,7 @@ class DevicesViewModelTest { @Test fun `given the viewModel when initializing it then view state is updated with current device full info list`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - val deviceFullInfoList = givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() + val deviceFullInfoList = givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) // When val viewModelTest = createViewModel().test() @@ -168,10 +183,6 @@ class DevicesViewModelTest { @Test fun `given the viewModel when initializing it then devices are refreshed on crypto devices change`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When createViewModel() @@ -183,10 +194,6 @@ class DevicesViewModelTest { @Test fun `given current session can be verified when handling verify current session action then self verification event is posted`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns true @@ -207,10 +214,6 @@ class DevicesViewModelTest { @Test fun `given current session cannot be verified when handling verify current session action then reset secrets event is posted`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns false @@ -228,18 +231,128 @@ class DevicesViewModelTest { } } - private fun givenVerificationService(): FakeVerificationService { - val fakeVerificationService = fakeActiveSessionHolder - .fakeSession - .fakeCryptoService - .fakeVerificationService - fakeVerificationService.givenAddListenerSucceeds() - fakeVerificationService.givenRemoveListenerSucceeds() - return fakeVerificationService + @Test + fun `given server error during multiSignout when handling multiSignout other sessions action then signout process is performed`() { + // Given + val serverError = Failure.OtherServerError(errorBody = "", httpCode = HttpsURLConnection.HTTP_UNAUTHORIZED) + fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), serverError) + val expectedViewState = givenInitialViewState() + fakeStringProvider.given(R.string.authentication_error, AUTH_ERROR_MESSAGE) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + + // Then + viewModelTest + .assertStatesChanges( + expectedViewState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvent { it is DevicesViewEvent.SignoutError && it.error.message == AUTH_ERROR_MESSAGE } + .finish() + } + + @Test + fun `given unexpected error during multiSignout when handling multiSignout action then signout process is performed`() { + // Given + val error = Exception() + fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), error) + val expectedViewState = givenInitialViewState() + fakeStringProvider.given(R.string.matrix_error, AN_ERROR_MESSAGE) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + + // Then + viewModelTest + .assertStatesChanges( + expectedViewState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvent { it is DevicesViewEvent.SignoutError && it.error.message == AN_ERROR_MESSAGE } + .finish() + } + + @Test + fun `given reAuth is needed during multiSignout when handling multiSignout action then requestReAuth is sent and pending auth is stored`() { + // Given + val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), fakeInterceptSignoutFlowResponseUseCase) + val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) + val expectedReAuthEvent = DevicesViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + + // 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 + justRun { fakePendingAuthHandler.instance.ssoAuthDone() } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.SsoAuthDone) + + // 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 + justRun { fakePendingAuthHandler.instance.passwordAuthDone(any()) } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.PasswordAuthDone(A_PASSWORD)) + + // 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 + justRun { fakePendingAuthHandler.instance.reAuthCancelled() } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.ReAuthCancelled) + + // Then + viewModelTest.finish() + verifyAll { + fakePendingAuthHandler.instance.reAuthCancelled() + } } private fun givenCurrentSessionCrossSigningInfo(): CurrentSessionCrossSigningInfo { val currentSessionCrossSigningInfo = mockk() + every { currentSessionCrossSigningInfo.deviceId } returns A_CURRENT_DEVICE_ID every { getCurrentSessionCrossSigningInfoUseCase.execute() } returns flowOf(currentSessionCrossSigningInfo) return currentSessionCrossSigningInfo } @@ -247,14 +360,19 @@ class DevicesViewModelTest { /** * Generate mocked deviceFullInfo list with 1 unverified and inactive + 1 verified and active. */ - private fun givenDeviceFullInfoList(): List { + private fun givenDeviceFullInfoList(deviceId1: String, deviceId2: String): List { val verifiedCryptoDeviceInfo = mockk() every { verifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = true, locallyVerified = true) val unverifiedCryptoDeviceInfo = mockk() every { unverifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false) + val deviceInfo1 = mockk() + every { deviceInfo1.deviceId } returns deviceId1 + val deviceInfo2 = mockk() + every { deviceInfo2.deviceId } returns deviceId2 + val deviceFullInfo1 = DeviceFullInfo( - deviceInfo = mockk(), + deviceInfo = deviceInfo1, cryptoDeviceInfo = verifiedCryptoDeviceInfo, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, isInactive = false, @@ -263,7 +381,7 @@ class DevicesViewModelTest { matrixClientInfo = MatrixClientInfoContent(), ) val deviceFullInfo2 = DeviceFullInfo( - deviceInfo = mockk(), + deviceInfo = deviceInfo2, cryptoDeviceInfo = unverifiedCryptoDeviceInfo, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, isInactive = true, @@ -277,7 +395,15 @@ class DevicesViewModelTest { return deviceFullInfoList } - private fun givenRefreshDevicesOnCryptoDevicesChange() { - coEvery { refreshDevicesOnCryptoDevicesChangeUseCase.execute() } just runs + private fun givenInitialViewState(): DevicesViewState { + val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo() + val deviceFullInfoList = givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) + return DevicesViewState( + currentSessionCrossSigningInfo = currentSessionCrossSigningInfo, + devices = Success(deviceFullInfoList), + unverifiedSessionsCount = 1, + inactiveSessionsCount = 1, + isLoading = false, + ) } }