From 69dc31681d9c79a8821aaf92c30fbc0786137d4e Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 25 Feb 2022 14:22:17 -0500 Subject: [PATCH] Apply server returned group patch instead of local only. --- .../securesms/groups/GroupManager.java | 2 +- .../securesms/groups/GroupManagerV2.java | 164 +++++++++++------- .../groups/v2/GroupCandidateHelper.java | 2 +- .../v2/processing/GroupsV2StateProcessor.java | 2 +- .../securesms/TestZkGroupServer.java | 49 ++++++ .../securesms/database/GroupTestUtil.kt | 37 +++- .../groups/GroupManagerV2Test_edit.kt | 158 +++++++++++++++++ .../java/org/signal/core/util/ThreadUtil.java | 8 +- .../api/groupsv2/GroupsV2Operations.java | 3 +- 9 files changed, 352 insertions(+), 73 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/TestZkGroupServer.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index ac068ddfb..fb8e8ad08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -161,7 +161,7 @@ public final class GroupManager { throws GroupChangeBusyException, GroupChangeFailedException, GroupInsufficientRightsException, GroupNotAMemberException, IOException { try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { - edit.ejectMember(recipient.getId(), false); + edit.ejectMember(recipient.requireServiceId(), false); Log.i(TAG, "Member removed from group " + groupId); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 29be4feaa..19c74479e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -4,6 +4,7 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import com.annimon.stream.Collectors; @@ -97,16 +98,39 @@ final class GroupManagerV2 { private final GroupsV2StateProcessor groupsV2StateProcessor; private final ACI selfAci; private final GroupCandidateHelper groupCandidateHelper; + private final SendGroupUpdateHelper sendGroupUpdateHelper; GroupManagerV2(@NonNull Context context) { + this(context, + SignalDatabase.groups(), + ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(), + ApplicationDependencies.getGroupsV2Operations(), + ApplicationDependencies.getGroupsV2Authorization(), + ApplicationDependencies.getGroupsV2StateProcessor(), + SignalStore.account().requireAci(), + new GroupCandidateHelper(context), + new SendGroupUpdateHelper(context)); + } + + @VisibleForTesting GroupManagerV2(Context context, + GroupDatabase groupDatabase, + GroupsV2Api groupsV2Api, + GroupsV2Operations groupsV2Operations, + GroupsV2Authorization authorization, + GroupsV2StateProcessor groupsV2StateProcessor, + ACI selfAci, + GroupCandidateHelper groupCandidateHelper, + SendGroupUpdateHelper sendGroupUpdateHelper) + { this.context = context; - this.groupDatabase = SignalDatabase.groups(); - this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(); - this.groupsV2Operations = ApplicationDependencies.getGroupsV2Operations(); - this.authorization = ApplicationDependencies.getGroupsV2Authorization(); - this.groupsV2StateProcessor = ApplicationDependencies.getGroupsV2StateProcessor(); - this.selfAci = SignalStore.account().requireAci(); - this.groupCandidateHelper = new GroupCandidateHelper(context); + this.groupDatabase = groupDatabase; + this.groupsV2Api = groupsV2Api; + this.groupsV2Operations = groupsV2Operations; + this.authorization = authorization; + this.groupsV2StateProcessor = groupsV2StateProcessor; + this.selfAci = selfAci; + this.groupCandidateHelper = groupCandidateHelper; + this.sendGroupUpdateHelper = sendGroupUpdateHelper; } @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password) @@ -234,7 +258,7 @@ final class GroupManagerV2 { @WorkerThread void sendNoopGroupUpdate(@NonNull GroupMasterKey masterKey, @NonNull DecryptedGroup currentState) { - sendGroupUpdate(masterKey, new GroupMutation(currentState, DecryptedGroupChange.newBuilder().build(), currentState), null); + sendGroupUpdateHelper.sendGroupUpdate(masterKey, new GroupMutation(currentState, DecryptedGroupChange.newBuilder().build(), currentState), null); } @@ -273,7 +297,7 @@ final class GroupManagerV2 { .setEditor(selfAci.toByteString()) .build(); - RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, new GroupMutation(null, groupChange, decryptedGroup), null); + RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(masterKey, new GroupMutation(null, groupChange, decryptedGroup), null); return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, recipientAndThread.threadId, @@ -420,7 +444,6 @@ final class GroupManagerV2 { @NonNull GroupManager.GroupActionResult leaveGroup() throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { - Recipient self = Recipient.self(); GroupDatabase.GroupRecord groupRecord = groupDatabase.getGroup(groupId).get(); List pendingMembersList = groupRecord.requireV2GroupProperties().getDecryptedGroup().getPendingMembersList(); Optional selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfAci.uuid()); @@ -432,17 +455,15 @@ final class GroupManagerV2 { throw new AssertionError(e); } } else { - return ejectMember(self.getId(), true); + return ejectMember(ServiceId.from(selfAci.uuid()), true); } } @WorkerThread - @NonNull GroupManager.GroupActionResult ejectMember(@NonNull RecipientId recipientId, boolean allowWhenBlocked) + @NonNull GroupManager.GroupActionResult ejectMember(@NonNull ServiceId serviceId, boolean allowWhenBlocked) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { - Recipient recipient = Recipient.resolved(recipientId); - - return commitChangeWithConflictResolution(groupOperations.createRemoveMembersChange(Collections.singleton(recipient.requireServiceId().uuid())), allowWhenBlocked); + return commitChangeWithConflictResolution(groupOperations.createRemoveMembersChange(Collections.singleton(serviceId.uuid())), allowWhenBlocked); } @WorkerThread @@ -633,20 +654,21 @@ final class GroupManagerV2 { throw new GroupChangeFailedException("Group is blocked."); } + previousGroupState = v2GroupProperties.getDecryptedGroup(); + + GroupChange signedGroupChange = commitToServer(changeActions); try { - previousGroupState = v2GroupProperties.getDecryptedGroup(); - decryptedChange = groupOperations.decryptChange(changeActions, selfAci.uuid()); + decryptedChange = groupOperations.decryptChange(signedGroupChange, false).get(); decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { Log.w(TAG, e); throw new IOException(e); } - GroupChange signedGroupChange = commitToServer(changeActions); groupDatabase.update(groupId, decryptedGroupState); GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState); - RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange); + RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange); int newMembersCount = decryptedChange.getNewMembersCount(); List newPendingMembers = getPendingMemberRecipientIds(decryptedChange.getNewPendingMembersList()); @@ -862,7 +884,7 @@ final class GroupManagerV2 { } else if (requestToJoin) { Log.i(TAG, "Requested to join, cannot send update"); - RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange, false); + RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange, false); return new GroupManager.GroupActionResult(groupRecipient, recipientAndThread.threadId, @@ -887,7 +909,7 @@ final class GroupManagerV2 { System.currentTimeMillis(), decryptedChange); - RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange); + RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange); return new GroupManager.GroupActionResult(groupRecipient, recipientAndThread.threadId, @@ -1086,7 +1108,7 @@ final class GroupManagerV2 { groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision())); - sendGroupUpdate(groupMasterKey, new GroupMutation(decryptedGroup, decryptedChange, newGroup), signedGroupChange, false); + sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, new GroupMutation(decryptedGroup, decryptedChange, newGroup), signedGroupChange, false); } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { throw new GroupChangeFailedException(e); } @@ -1139,55 +1161,65 @@ final class GroupManagerV2 { } } - private @NonNull RecipientAndThread sendGroupUpdate(@NonNull GroupMasterKey masterKey, - @NonNull GroupMutation groupMutation, - @Nullable GroupChange signedGroupChange) - { - return sendGroupUpdate(masterKey, groupMutation, signedGroupChange, true); - } + @VisibleForTesting + static class SendGroupUpdateHelper { - private @NonNull RecipientAndThread sendGroupUpdate(@NonNull GroupMasterKey masterKey, - @NonNull GroupMutation groupMutation, - @Nullable GroupChange signedGroupChange, - boolean sendToMembers) - { - GroupId.V2 groupId = GroupId.v2(masterKey); - Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); - DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, groupMutation, signedGroupChange); - OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, - decryptedGroupV2Context, - null, - System.currentTimeMillis(), - 0, - false, - null, - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList()); + private final Context context; + + SendGroupUpdateHelper(Context context) { + this.context = context; + } + + @NonNull RecipientAndThread sendGroupUpdate(@NonNull GroupMasterKey masterKey, + @NonNull GroupMutation groupMutation, + @Nullable GroupChange signedGroupChange) + { + return sendGroupUpdate(masterKey, groupMutation, signedGroupChange, true); + } + + @NonNull RecipientAndThread sendGroupUpdate(@NonNull GroupMasterKey masterKey, + @NonNull GroupMutation groupMutation, + @Nullable GroupChange signedGroupChange, + boolean sendToMembers) + { + GroupId.V2 groupId = GroupId.v2(masterKey); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, groupMutation, signedGroupChange); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, + decryptedGroupV2Context, + null, + System.currentTimeMillis(), + 0, + false, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); - DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange(); + DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange(); - if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { - if (sendToMembers) { - ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, groupMutation.getNewGroupState(), outgoingMessage)); - } - - return new RecipientAndThread(groupRecipient, -1); - } else { - if (sendToMembers) { - long threadId = MessageSender.send(context, outgoingMessage, -1, false, null, null); - return new RecipientAndThread(groupRecipient, threadId); - } else { - long threadId = SignalDatabase.threads().getOrCreateValidThreadId(outgoingMessage.getRecipient(), -1, outgoingMessage.getDistributionType()); - try { - long messageId = SignalDatabase.mms().insertMessageOutbox(outgoingMessage, threadId, false, null); - SignalDatabase.mms().markAsSent(messageId, true); - SignalDatabase.threads().update(threadId, true); - } catch (MmsException e) { - throw new AssertionError(e); + if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { + if (sendToMembers) { + ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, groupMutation.getNewGroupState(), outgoingMessage)); + } + + return new RecipientAndThread(groupRecipient, -1); + } else { + if (sendToMembers) { + long threadId = MessageSender.send(context, outgoingMessage, -1, false, null, null); + return new RecipientAndThread(groupRecipient, threadId); + } else { + long threadId = SignalDatabase.threads().getOrCreateValidThreadId(outgoingMessage.getRecipient(), -1, outgoingMessage.getDistributionType()); + try { + long messageId = SignalDatabase.mms().insertMessageOutbox(outgoingMessage, threadId, false, null); + SignalDatabase.mms().markAsSent(messageId, true); + SignalDatabase.threads().update(threadId, true); + } catch (MmsException e) { + throw new AssertionError(e); + } + return new RecipientAndThread(groupRecipient, threadId); } - return new RecipientAndThread(groupRecipient, threadId); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java index 5314186af..8b0493b48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java @@ -26,7 +26,7 @@ import java.util.HashSet; import java.util.Locale; import java.util.Set; -public final class GroupCandidateHelper { +public class GroupCandidateHelper { private final SignalServiceAccountManager signalServiceAccountManager; private final RecipientDatabase recipientDatabase; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 6e94e1451..94cc317ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -71,7 +71,7 @@ import java.util.stream.Collectors; /** * Advances a groups state to a specified revision. */ -public final class GroupsV2StateProcessor { +public class GroupsV2StateProcessor { private static final String TAG = Log.tag(GroupsV2StateProcessor.class); diff --git a/app/src/test/java/org/thoughtcrime/securesms/TestZkGroupServer.java b/app/src/test/java/org/thoughtcrime/securesms/TestZkGroupServer.java new file mode 100644 index 000000000..2c1a3b79e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/TestZkGroupServer.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms; + +import org.signal.zkgroup.ServerPublicParams; +import org.signal.zkgroup.ServerSecretParams; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.GroupPublicParams; +import org.signal.zkgroup.profiles.ProfileKeyCommitment; +import org.signal.zkgroup.profiles.ProfileKeyCredentialPresentation; +import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest; +import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; +import org.signal.zkgroup.profiles.ServerZkProfileOperations; +import org.whispersystems.signalservice.test.LibSignalLibraryUtil; + +import java.util.UUID; + +/** + * Provides Zk group operations that the server would provide. + * Copied in app from libsignal + */ +public final class TestZkGroupServer { + + private final ServerPublicParams serverPublicParams; + private final ServerZkProfileOperations serverZkProfileOperations; + + public TestZkGroupServer() { + LibSignalLibraryUtil.assumeLibSignalSupportedOnOS(); + + ServerSecretParams serverSecretParams = ServerSecretParams.generate(); + + serverPublicParams = serverSecretParams.getPublicParams(); + serverZkProfileOperations = new ServerZkProfileOperations(serverSecretParams); + } + + public ServerPublicParams getServerPublicParams() { + return serverPublicParams; + } + + public ProfileKeyCredentialResponse getProfileKeyCredentialResponse(ProfileKeyCredentialRequest request, UUID uuid, ProfileKeyCommitment commitment) throws VerificationFailedException { + return serverZkProfileOperations.issueProfileKeyCredential(request, uuid, commitment); + } + + public void assertProfileKeyCredentialPresentation(GroupPublicParams publicParams, ProfileKeyCredentialPresentation profileKeyCredentialPresentation) { + try { + serverZkProfileOperations.verifyProfileKeyCredentialPresentation(publicParams, profileKeyCredentialPresentation); + } catch (VerificationFailedException e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt index 6e1242cee..233f56549 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import com.google.protobuf.ByteString import org.signal.storageservice.protos.groups.AccessControl +import org.signal.storageservice.protos.groups.GroupChange import org.signal.storageservice.protos.groups.Member import org.signal.storageservice.protos.groups.local.DecryptedGroup import org.signal.storageservice.protos.groups.local.DecryptedGroupChange @@ -17,6 +18,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.libsignal.util.guava.Optional import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.push.ServiceId import java.util.UUID @@ -69,12 +71,39 @@ class ChangeSet { } } -class GroupStateTestData(private val masterKey: GroupMasterKey) { +class GroupChangeData(private val revision: Int, private val groupOperations: GroupsV2Operations.GroupOperations) { + private val groupChangeBuilder: GroupChange.Builder = GroupChange.newBuilder() + private val actionsBuilder: GroupChange.Actions.Builder = GroupChange.Actions.newBuilder() + var changeEpoch: Int = GroupsV2Operations.HIGHEST_KNOWN_EPOCH + + val groupChange: GroupChange + get() { + return groupChangeBuilder + .setChangeEpoch(changeEpoch) + .setActions(actionsBuilder.setRevision(revision).build().toByteString()) + .build() + } + + fun source(serviceId: ServiceId) { + actionsBuilder.sourceUuid = groupOperations.encryptUuid(serviceId.uuid()) + } + + fun deleteMember(serviceId: ServiceId) { + actionsBuilder.addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder().setDeletedUserId(groupOperations.encryptUuid(serviceId.uuid()))) + } + + fun modifyRole(serviceId: ServiceId, role: Member.Role) { + actionsBuilder.addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(groupOperations.encryptUuid(serviceId.uuid())).setRole(role)) + } +} + +class GroupStateTestData(private val masterKey: GroupMasterKey, private val groupOperations: GroupsV2Operations.GroupOperations? = null) { var localState: DecryptedGroup? = null var groupRecord: Optional = Optional.absent() var serverState: DecryptedGroup? = null var changeSet: ChangeSet? = null + var groupChange: GroupChange? = null var includeFirst: Boolean = false var requestedRevision: Int = 0 @@ -120,6 +149,12 @@ class GroupStateTestData(private val masterKey: GroupMasterKey) { this.requestedRevision = requestedRevision this.includeFirst = includeFirst } + + fun groupChange(revision: Int, init: GroupChangeData.() -> Unit) { + val groupChangeData = GroupChangeData(revision, groupOperations!!) + groupChangeData.init() + this.groupChange = groupChangeData.groupChange + } } fun groupRecord( diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt new file mode 100644 index 000000000..542200398 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt @@ -0,0 +1,158 @@ +@file:Suppress("ClassName") + +package org.thoughtcrime.securesms.groups + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.hamcrest.Matchers.`is` +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.ThreadUtil +import org.signal.core.util.logging.Log +import org.signal.storageservice.protos.groups.Member +import org.signal.storageservice.protos.groups.local.DecryptedGroup +import org.signal.storageservice.protos.groups.local.DecryptedMember +import org.signal.zkgroup.groups.GroupMasterKey +import org.signal.zkgroup.groups.GroupSecretParams +import org.thoughtcrime.securesms.SignalStoreRule +import org.thoughtcrime.securesms.TestZkGroupServer +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.GroupStateTestData +import org.thoughtcrime.securesms.database.member +import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor +import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.testutil.SystemOutLogger +import org.thoughtcrime.securesms.util.Hex +import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider +import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations +import org.whispersystems.signalservice.api.push.ACI +import org.whispersystems.signalservice.api.push.ServiceId +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class GroupManagerV2Test_edit { + + companion object { + val server: TestZkGroupServer = TestZkGroupServer() + val masterKey: GroupMasterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + val groupSecretParams: GroupSecretParams = GroupSecretParams.deriveFromMasterKey(masterKey) + val groupId: GroupId.V2 = GroupId.v2(masterKey) + + val selfAci: ACI = ACI.from(UUID.randomUUID()) + val otherSid: ServiceId = ServiceId.from(UUID.randomUUID()) + val selfAndOthers: List = listOf(member(selfAci), member(otherSid)) + val others: List = listOf(member(otherSid)) + } + + private lateinit var groupDatabase: GroupDatabase + private lateinit var groupsV2API: GroupsV2Api + private lateinit var groupsV2Operations: GroupsV2Operations + private lateinit var groupsV2Authorization: GroupsV2Authorization + private lateinit var groupsV2StateProcessor: GroupsV2StateProcessor + private lateinit var groupCandidateHelper: GroupCandidateHelper + private lateinit var sendGroupUpdateHelper: GroupManagerV2.SendGroupUpdateHelper + private lateinit var groupOperations: GroupsV2Operations.GroupOperations + + private lateinit var patchedDecryptedGroup: ArgumentCaptor + + private lateinit var manager: GroupManagerV2 + + @get:Rule + val signalStore: SignalStoreRule = SignalStoreRule() + + @Suppress("UsePropertyAccessSyntax") + @Before + fun setUp() { + ThreadUtil.enforceAssertions = false + Log.initialize(SystemOutLogger()) + SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger()) + + val clientZkOperations = ClientZkOperations(server.getServerPublicParams()) + + groupDatabase = mock(GroupDatabase::class.java) + groupsV2API = mock(GroupsV2Api::class.java) + groupsV2Operations = GroupsV2Operations(clientZkOperations) + groupsV2Authorization = mock(GroupsV2Authorization::class.java) + groupsV2StateProcessor = mock(GroupsV2StateProcessor::class.java) + groupCandidateHelper = mock(GroupCandidateHelper::class.java) + sendGroupUpdateHelper = mock(GroupManagerV2.SendGroupUpdateHelper::class.java) + groupOperations = groupsV2Operations.forGroup(groupSecretParams) + + patchedDecryptedGroup = ArgumentCaptor.forClass(DecryptedGroup::class.java) + + manager = GroupManagerV2( + ApplicationProvider.getApplicationContext(), + groupDatabase, + groupsV2API, + groupsV2Operations, + groupsV2Authorization, + groupsV2StateProcessor, + selfAci, + groupCandidateHelper, + sendGroupUpdateHelper + ) + } + + private fun given(init: GroupStateTestData.() -> Unit) { + val data = GroupStateTestData(masterKey, groupOperations) + data.init() + + Mockito.doReturn(data.groupRecord).`when`(groupDatabase).getGroup(groupId) + Mockito.doReturn(data.groupRecord.get()).`when`(groupDatabase).requireGroup(groupId) + + Mockito.doReturn(GroupManagerV2.RecipientAndThread(Recipient.UNKNOWN, 1)).`when`(sendGroupUpdateHelper).sendGroupUpdate(Mockito.eq(masterKey), Mockito.any(), Mockito.any()) + + Mockito.doReturn(data.groupChange!!).`when`(groupsV2API).patchGroup(Mockito.any(), Mockito.any(), Mockito.any()) + } + + private fun editGroup(perform: GroupManagerV2.GroupEditor.() -> Unit) { + manager.edit(groupId).use { it.perform() } + } + + private fun then(then: (DecryptedGroup) -> Unit) { + Mockito.verify(groupDatabase).update(Mockito.eq(groupId), patchedDecryptedGroup.capture()) + then(patchedDecryptedGroup.value) + } + + @Test + fun `when you are the only admin, and then leave the group, server upgrades all other members to administrators and lets you leave`() { + given { + localState( + revision = 5, + members = listOf( + member(selfAci, role = Member.Role.ADMINISTRATOR), + member(otherSid) + ) + ) + groupChange(6) { + source(selfAci) + deleteMember(selfAci) + modifyRole(otherSid, Member.Role.ADMINISTRATOR) + } + } + + editGroup { + leaveGroup() + } + + then { patchedGroup -> + assertThat("Revision updated by one", patchedGroup.revision, `is`(6)) + assertThat("Self is no longer in the group", patchedGroup.membersList.find { it.uuid == selfAci.toByteString() }, Matchers.nullValue()) + assertThat("Other is now an admin in the group", patchedGroup.membersList.find { it.uuid == otherSid.toByteString() }?.role, `is`(Member.Role.ADMINISTRATOR)) + } + } +} diff --git a/core-util/src/main/java/org/signal/core/util/ThreadUtil.java b/core-util/src/main/java/org/signal/core/util/ThreadUtil.java index a05961187..dbd4df62f 100644 --- a/core-util/src/main/java/org/signal/core/util/ThreadUtil.java +++ b/core-util/src/main/java/org/signal/core/util/ThreadUtil.java @@ -4,6 +4,7 @@ import android.os.Handler; import android.os.Looper; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import org.signal.core.util.concurrent.TracingExecutor; import org.signal.core.util.concurrent.TracingExecutorService; @@ -19,6 +20,9 @@ public final class ThreadUtil { private static volatile Handler handler; + @VisibleForTesting + public static volatile boolean enforceAssertions = true; + private ThreadUtil() {} private static Handler getHandler() { @@ -37,13 +41,13 @@ public final class ThreadUtil { } public static void assertMainThread() { - if (!isMainThread()) { + if (!isMainThread() && enforceAssertions) { throw new AssertionError("Must run on main thread."); } } public static void assertNotMainThread() { - if (isMainThread()) { + if (isMainThread() && enforceAssertions) { throw new AssertionError("Cannot run on main thread."); } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index 1ffff969c..34e916498 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -732,7 +732,8 @@ public final class GroupsV2Operations { return UuidUtil.toByteString(decryptUuid(userId)); } - ByteString encryptUuid(UUID uuid) { + // Visible for Testing + public ByteString encryptUuid(UUID uuid) { return ByteString.copyFrom(clientZkGroupCipher.encryptUuid(uuid).serialize()); }