From a950ad7078aa3058adbf7d0f5a8e6262201af0c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Wed, 22 Nov 2023 15:25:56 +0100 Subject: [PATCH] feat: add use case to approve legal hold [WPB-4393] (#2239) * feat: add use case to approve legal hold [WPB-4393] * add doc comments * trigger build * update jvm tests * revert jvm tests --------- Co-authored-by: Yamil Medina --- .../kalium/logic/data/team/TeamRepository.kt | 6 + .../legalhold/ApproveLegalHoldUseCase.kt | 84 +++++++++ .../kalium/logic/feature/team/TeamScope.kt | 7 + .../logic/data/team/TeamRepositoryTest.kt | 20 +++ .../legalhold/ApproveLegalHoldUseCaseTest.kt | 165 ++++++++++++++++++ .../api/base/authenticated/TeamsApi.kt | 5 + .../api/v0/authenticated/TeamsApiV0.kt | 11 ++ .../kalium/api/v0/teams/TeamsApiV0Test.kt | 32 ++++ 8 files changed, 330 insertions(+) create mode 100644 logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCase.kt create mode 100644 logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCaseTest.kt diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/team/TeamRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/team/TeamRepository.kt index a31c9655f14..f648029984f 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/team/TeamRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/team/TeamRepository.kt @@ -52,6 +52,7 @@ interface TeamRepository { suspend fun removeTeamMember(teamId: String, userId: String): Either suspend fun updateTeam(team: Team): Either suspend fun syncServices(teamId: TeamId): Either + suspend fun approveLegalHold(teamId: TeamId, password: String?): Either } @Suppress("LongParameterList") @@ -172,4 +173,9 @@ internal class TeamDataSource( serviceDAO.insertMultiple(it) } } + + override suspend fun approveLegalHold(teamId: TeamId, password: String?): Either = wrapApiRequest { + teamsApi.approveLegalHold(teamId.value, selfUserId.value, password) + // TODO: should we update the legal hold status for the current user in the database? + } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCase.kt new file mode 100644 index 00000000000..58ab188e7b6 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCase.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.legalhold + +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.NetworkFailure +import com.wire.kalium.logic.StorageFailure +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.data.team.TeamRepository +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.flatMap +import com.wire.kalium.logic.functional.fold +import com.wire.kalium.network.exceptions.KaliumException +import com.wire.kalium.network.exceptions.isAccessDenied +import com.wire.kalium.network.exceptions.isBadRequest +import io.ktor.http.HttpStatusCode + +/** + * Use Case that allows the user to accept a requested legal hold. + */ +interface ApproveLegalHoldUseCase { + + /** + * Use case [ApproveLegalHoldUseCase] operation + * + * @param password password for the user account to confirm the action, can be empty for sso users + * @return a [ApproveLegalHoldUseCase.Result] indicating the operation result + */ + suspend operator fun invoke(password: String?): Result + + sealed class Result { + data object Success : Result() + sealed class Failure : Result() { + data class GenericFailure(val coreFailure: CoreFailure) : Failure() + data object InvalidPassword : Failure() + data object PasswordRequired : Failure() + } + } +} + +class ApproveLegalHoldUseCaseImpl internal constructor( + private val teamRepository: TeamRepository, + private val selfTeamIdProvider: SelfTeamIdProvider, +) : ApproveLegalHoldUseCase { + override suspend fun invoke(password: String?): ApproveLegalHoldUseCase.Result { + return selfTeamIdProvider() + .flatMap { + if (it == null) Either.Left(StorageFailure.DataNotFound) + else Either.Right(it) + } + .flatMap { teamId -> + teamRepository.approveLegalHold(teamId, password) + } + .fold({ handleError(it) }, { ApproveLegalHoldUseCase.Result.Success }) + } + + private fun handleError(failure: CoreFailure): ApproveLegalHoldUseCase.Result.Failure = + if (failure is NetworkFailure.ServerMiscommunication && failure.kaliumException is KaliumException.InvalidRequestError) + failure.kaliumException.let { error: KaliumException.InvalidRequestError -> + when { + error.errorResponse.code == HttpStatusCode.BadRequest.value && error.isBadRequest() -> + ApproveLegalHoldUseCase.Result.Failure.InvalidPassword + error.errorResponse.code == HttpStatusCode.Forbidden.value && error.isAccessDenied() -> + ApproveLegalHoldUseCase.Result.Failure.PasswordRequired + else -> ApproveLegalHoldUseCase.Result.Failure.GenericFailure(failure) + } + } + else ApproveLegalHoldUseCase.Result.Failure.GenericFailure(failure) +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/team/TeamScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/team/TeamScope.kt index c0fc5e57d31..6ad10d80ac4 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/team/TeamScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/team/TeamScope.kt @@ -22,6 +22,8 @@ import com.wire.kalium.logic.data.conversation.ConversationRepository import com.wire.kalium.logic.data.team.TeamRepository import com.wire.kalium.logic.data.user.UserRepository import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.feature.legalhold.ApproveLegalHoldUseCase +import com.wire.kalium.logic.feature.legalhold.ApproveLegalHoldUseCaseImpl import com.wire.kalium.logic.feature.user.IsSelfATeamMemberUseCase import com.wire.kalium.logic.feature.user.IsSelfATeamMemberUseCaseImpl @@ -45,4 +47,9 @@ class TeamScope internal constructor( ) val isSelfATeamMember: IsSelfATeamMemberUseCase get() = IsSelfATeamMemberUseCaseImpl(selfTeamIdProvider) + + val approveLegalHold: ApproveLegalHoldUseCase get() = ApproveLegalHoldUseCaseImpl( + teamRepository = teamRepository, + selfTeamIdProvider = selfTeamIdProvider, + ) } diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/team/TeamRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/team/TeamRepositoryTest.kt index c5b4142d858..c5f1fc769d5 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/team/TeamRepositoryTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/team/TeamRepositoryTest.kt @@ -260,6 +260,19 @@ class TeamRepositoryTest { .wasInvoked(once) } + @Test + fun givenTeamIdAndUserIdAndPassword_whenFetchingTeamMember_thenTeamMemberShouldBeSuccessful() = runTest { + // given + val (_, teamRepository) = Arrangement() + .withApiApproveLegalHoldSuccess() + .withGetUsersInfoSuccess() + .arrange() + // when + val result = teamRepository.approveLegalHold(teamId = TeamId(value = "teamId"), password = "password") + // then + result.shouldSucceed() + } + private class Arrangement { @Mock val teamDAO = configure(mock(classOf())) { @@ -326,6 +339,13 @@ class TeamRepositoryTest { .thenReturn(NetworkResponse.Success(value = SERVICE_DETAILS_RESPONSE, headers = mapOf(), httpCode = 200)) } + fun withApiApproveLegalHoldSuccess() = apply { + given(teamsApi) + .suspendFunction(teamsApi::approveLegalHold) + .whenInvokedWith(any(), any()) + .thenReturn(NetworkResponse.Success(value = Unit, headers = mapOf(), httpCode = 200)) + } + fun arrange() = this to teamRepository companion object { diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCaseTest.kt new file mode 100644 index 00000000000..a6b36f28adc --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/legalhold/ApproveLegalHoldUseCaseTest.kt @@ -0,0 +1,165 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.legalhold + +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.NetworkFailure +import com.wire.kalium.logic.StorageFailure +import com.wire.kalium.logic.data.id.SelfTeamIdProvider +import com.wire.kalium.logic.data.id.TeamId +import com.wire.kalium.logic.data.team.TeamRepository +import com.wire.kalium.logic.framework.TestTeam +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.test_util.TestNetworkException +import com.wire.kalium.network.exceptions.KaliumException +import io.ktor.utils.io.errors.IOException +import io.mockative.Mock +import io.mockative.anything +import io.mockative.eq +import io.mockative.given +import io.mockative.mock +import io.mockative.once +import io.mockative.verify +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertSame + +class ApproveLegalHoldUseCaseTest { + + @Test + fun givenApproveLegalHoldParams_whenApproving_thenTheRepositoryShouldBeCalledWithCorrectParameters() = runTest { + // given + val selfTeamId = TeamId(TestTeam.TEAM.id) + val password = "password" + val (arrangement, useCase) = Arrangement() + .withGetSelfTeamResult(Either.Right(selfTeamId)) + .withApproveLegalHoldResult(Either.Right(Unit)) + .arrange() + // when + useCase.invoke(password) + // then + verify(arrangement.teamRepository) + .suspendFunction(arrangement.teamRepository::approveLegalHold) + .with(eq(selfTeamId), eq(password)) + .wasInvoked(once) + } + + @Test + fun givenGetSelfTeamIdFails_whenApproving_thenDataNotFoundErrorShouldBeReturned() = runTest { + // given + val password = "password" + val (_, useCase) = Arrangement() + .withGetSelfTeamResult(Either.Left(StorageFailure.DataNotFound)) + .arrange() + // when + val result = useCase.invoke(password) + // then + assertIs(result) + assertSame(StorageFailure.DataNotFound, result.coreFailure) + } + + @Test + fun givenApproveFailsDueToGenericError_whenApproving_thenGenericErrorShouldBeReturned() = runTest { + // given + val selfTeamId = TeamId(TestTeam.TEAM.id) + val password = "password" + val failure = NetworkFailure.ServerMiscommunication(KaliumException.GenericError(IOException("no internet"))) + val (_, useCase) = Arrangement() + .withGetSelfTeamResult(Either.Right(selfTeamId)) + .withApproveLegalHoldResult(Either.Left(failure)) + .arrange() + // when + val result = useCase(password) + // then + assertIs(result) + assertSame(failure, result.coreFailure) + } + + @Test + fun givenApproveFailsDueToBadRequest_whenApproving_thenInvalidPasswordErrorShouldBeReturned() = runTest { + // given + val selfTeamId = TeamId(TestTeam.TEAM.id) + val password = "password" + val failure = NetworkFailure.ServerMiscommunication(TestNetworkException.badRequest) + val (_, useCase) = Arrangement() + .withGetSelfTeamResult(Either.Right(selfTeamId)) + .withApproveLegalHoldResult(Either.Left(failure)) + .arrange() + // when + val result = useCase(password) + // then + assertIs(result) + } + + @Test + fun givenApproveFailsDueToAccessDenied_whenDeleting_thenPasswordRequiredErrorShouldBeReturned() = runTest { + // given + val selfTeamId = TeamId(TestTeam.TEAM.id) + val failure = NetworkFailure.ServerMiscommunication(TestNetworkException.accessDenied) + val (_, useCase) = Arrangement() + .withGetSelfTeamResult(Either.Right(selfTeamId)) + .withApproveLegalHoldResult(Either.Left(failure)) + .arrange() + // when + val result = useCase(null) + // then + assertIs(result) + } + + @Test + fun givenApproveSucceeds_whenApproving_thenSuccessShouldBeReturned() = runTest { + // given + val selfTeamId = TeamId(TestTeam.TEAM.id) + val password = "password" + val (_, useCase) = Arrangement() + .withGetSelfTeamResult(Either.Right(selfTeamId)) + .withApproveLegalHoldResult(Either.Right(Unit)) + .arrange() + // when + val result = useCase(password) + // then + assertIs(result) + } + + private class Arrangement { + + @Mock + val teamRepository: TeamRepository = mock(TeamRepository::class) + @Mock + val selfTeamIdProvider: SelfTeamIdProvider = mock(SelfTeamIdProvider::class) + + val useCase: ApproveLegalHoldUseCase by lazy { ApproveLegalHoldUseCaseImpl(teamRepository, selfTeamIdProvider) } + + fun arrange() = this to useCase + + fun withGetSelfTeamResult(result: Either) = apply { + given(selfTeamIdProvider) + .suspendFunction(selfTeamIdProvider::invoke) + .whenInvoked() + .thenReturn(result) + } + + fun withApproveLegalHoldResult(result: Either) = apply { + given(teamRepository) + .suspendFunction(teamRepository::approveLegalHold) + .whenInvokedWith(anything(), anything()) + .thenReturn(result) + } + } +} diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/TeamsApi.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/TeamsApi.kt index b8348d0bcb4..6d741a8ac9e 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/TeamsApi.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/TeamsApi.kt @@ -57,6 +57,10 @@ interface TeamsApi { data class TeamMemberIdList( @SerialName("user_ids") val userIds: List ) + @Serializable + data class PasswordRequest( + @SerialName("password") val password: String? + ) sealed interface GetTeamsOptionsInterface @@ -89,6 +93,7 @@ interface TeamsApi { suspend fun getTeamMember(teamId: TeamId, userId: NonQualifiedUserId): NetworkResponse suspend fun getTeamInfo(teamId: TeamId): NetworkResponse suspend fun whiteListedServices(teamId: TeamId, size: Int = DEFAULT_SERVICES_SIZE): NetworkResponse + suspend fun approveLegalHold(teamId: TeamId, userId: NonQualifiedUserId, password: String?): NetworkResponse companion object { const val DEFAULT_SERVICES_SIZE = 100 // this number is copied from the web client diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/TeamsApiV0.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/TeamsApiV0.kt index 10b14a2c441..52e4997edcf 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/TeamsApiV0.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/TeamsApiV0.kt @@ -20,6 +20,7 @@ package com.wire.kalium.network.api.v0.authenticated import com.wire.kalium.network.AuthenticatedNetworkClient import com.wire.kalium.network.api.base.authenticated.TeamsApi +import com.wire.kalium.network.api.base.authenticated.client.PasswordRequest import com.wire.kalium.network.api.base.model.NonQualifiedConversationId import com.wire.kalium.network.api.base.model.NonQualifiedUserId import com.wire.kalium.network.api.base.model.ServiceDetailResponse @@ -31,6 +32,7 @@ import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post +import io.ktor.client.request.put import io.ktor.client.request.setBody internal open class TeamsApiV0 internal constructor( @@ -76,6 +78,13 @@ internal open class TeamsApiV0 internal constructor( httpClient.get("$PATH_TEAMS/$teamId/$PATH_MEMBERS/$userId") } + override suspend fun approveLegalHold(teamId: TeamId, userId: NonQualifiedUserId, password: String?): NetworkResponse = + wrapKaliumResponse { + httpClient.put("$PATH_TEAMS/$teamId/$PATH_LEGAL_HOLD/$userId/$PATH_APPROVE") { + setBody(PasswordRequest(password)) + } + } + private companion object { const val PATH_TEAMS = "teams" const val PATH_CONVERSATIONS = "conversations" @@ -83,5 +92,7 @@ internal open class TeamsApiV0 internal constructor( const val PATH_MEMBERS_BY_IDS = "get-members-by-ids-using-post" const val PATH_SERVICES = "services" const val PATH_WHITELISTED = "whitelisted" + const val PATH_LEGAL_HOLD = "legalhold" + const val PATH_APPROVE = "approve" } } diff --git a/network/src/commonTest/kotlin/com/wire/kalium/api/v0/teams/TeamsApiV0Test.kt b/network/src/commonTest/kotlin/com/wire/kalium/api/v0/teams/TeamsApiV0Test.kt index acbe0bc5341..1d3e37ce4fe 100644 --- a/network/src/commonTest/kotlin/com/wire/kalium/api/v0/teams/TeamsApiV0Test.kt +++ b/network/src/commonTest/kotlin/com/wire/kalium/api/v0/teams/TeamsApiV0Test.kt @@ -24,13 +24,17 @@ import com.wire.kalium.model.ServiceDetailsResponseJson import com.wire.kalium.model.TeamsResponsesJson import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.model.ErrorResponse +import com.wire.kalium.network.api.base.model.NonQualifiedUserId import com.wire.kalium.network.api.base.model.ServiceDetailResponse +import com.wire.kalium.network.api.base.model.TeamId import com.wire.kalium.network.api.v0.authenticated.TeamsApiV0 import com.wire.kalium.network.exceptions.KaliumException +import com.wire.kalium.network.tools.KtxSerializer import com.wire.kalium.network.utils.NetworkResponse import io.ktor.http.HttpStatusCode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -120,11 +124,39 @@ internal class TeamsApiV0Test : ApiTest() { } } + private fun testApprovingLegalHold(teamId: TeamId, userId: NonQualifiedUserId, password: String?) = runTest { + val expectedRequestBody = KtxSerializer.json.encodeToString(TeamsApi.PasswordRequest(password)) + val networkClient = mockAuthenticatedNetworkClient( + "", + statusCode = HttpStatusCode.OK, + assertion = { + assertPut() + assertJson() + assertNoQueryParams() + assertJsonBodyContent(expectedRequestBody) + assertPathEqual("/$PATH_TEAMS/$teamId/$PATH_LEGAL_HOLD/$userId/$PATH_APPROVE") + } + ) + val teamsApi: TeamsApi = TeamsApiV0(networkClient) + teamsApi.approveLegalHold(teamId, userId, password) + } + + @Test + fun givenAValidTeamIdAndUserIdAndPassword_whenApprovingLegalHold_theRequestShouldBeConfiguredCorrectly() = + testApprovingLegalHold(DUMMY_TEAM_ID, DUMMY_USER_ID, "password") + + @Test + fun givenAValidTeamIdAndUserIdAndNoPassword_whenApprovingLegalHold_theRequestShouldBeConfiguredCorrectly() = + testApprovingLegalHold(DUMMY_TEAM_ID, DUMMY_USER_ID, null) + private companion object { const val PATH_TEAMS = "teams" const val PATH_CONVERSATIONS = "conversations" const val PATH_MEMBERS = "members" + const val PATH_LEGAL_HOLD = "legalhold" + const val PATH_APPROVE = "approve" const val DUMMY_TEAM_ID = "770b0623-ffd5-4e08-8092-7a6b9b9ca3b4" + const val DUMMY_USER_ID = "96a6e8e4-6420-49db-aa83-2711edf7580d" val GET_TEAM_MEMBER_CLIENT_RESPONSE = TeamsResponsesJson.GetTeamsMembers.validGetTeamsMembers val SERVICES_LIST_RESPONSE = ServiceDetailsResponseJson.valid }