diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java index 812401f9ff..430ee7d3b2 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java @@ -1,8 +1,13 @@ package org.whispersystems.signalservice.api.util; +import com.google.protobuf.ByteString; + import org.whispersystems.libsignal.util.guava.Optional; import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; import java.util.UUID; import java.util.regex.Pattern; @@ -43,4 +48,12 @@ public final class UuidUtil { return buffer.array(); } + + public static ByteString toByteString(UUID uuid) { + return ByteString.copyFrom(toByteArray(uuid)); + } + + public static UUID fromByteString(ByteString bytes) { + return parseOrThrow(bytes.toByteArray()); + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtil.java new file mode 100644 index 0000000000..12d951a752 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtil.java @@ -0,0 +1,212 @@ +package org.whispersystems.signalservice.internal.groupsv2; + +import com.google.protobuf.ByteString; + +import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; + +import java.util.HashMap; +import java.util.List; + +public final class GroupChangeUtil { + + private GroupChangeUtil() { + } + + /** + * The maximum field we know about here. + */ + static final int CHANGE_ACTION_MAX_FIELD = 14; + + /** + * True iff there are no change actions. + */ + public static boolean changeIsEmpty(GroupChange.Actions change) { + return change.getAddMembersCount() == 0 && // field 3 + change.getDeleteMembersCount() == 0 && // field 4 + change.getModifyMemberRolesCount() == 0 && // field 5 + change.getModifyMemberProfileKeysCount() == 0 && // field 6 + change.getAddPendingMembersCount() == 0 && // field 7 + change.getDeletePendingMembersCount() == 0 && // field 8 + change.getPromotePendingMembersCount() == 0 && // field 9 + !change.hasModifyTitle() && // field 10 + !change.hasModifyAvatar() && // field 11 + !change.hasModifyDisappearingMessagesTimer() && // field 12 + !change.hasModifyAttributesAccess() && // field 13 + !change.hasModifyMemberAccess(); // field 14 + } + + /** + * Given the latest group state and a conflicting change, decides which changes to carry forward + * and returns a new group change which could be empty. + *

+ * Titles, avatars, and other settings are carried forward if they are different. Last writer wins. + *

+ * Membership additions and removals also respect last writer wins and are removed if they have + * already been applied. e.g. you add someone but they are already added. + *

+ * Membership additions will be altered to {@link GroupChange.Actions.PromotePendingMemberAction} + * if someone has invited them since. + * + * @param groupState Latest group state in plaintext. + * @param conflictingChange The potentially conflicting change in plaintext. + * @param encryptedChange Encrypted version of the {@param conflictingChange}. + * @return A new change builder. + */ + public static GroupChange.Actions.Builder resolveConflict(DecryptedGroup groupState, + DecryptedGroupChange conflictingChange, + GroupChange.Actions encryptedChange) + { + GroupChange.Actions.Builder result = GroupChange.Actions.newBuilder(encryptedChange); + HashMap fullMembersByUuid = new HashMap<>(groupState.getMembersCount()); + HashMap pendingMembersByUuid = new HashMap<>(groupState.getPendingMembersCount()); + + for (DecryptedMember member : groupState.getMembersList()) { + fullMembersByUuid.put(member.getUuid(), member); + } + + for (DecryptedPendingMember member : groupState.getPendingMembersList()) { + pendingMembersByUuid.put(member.getUuid(), member); + } + + resolveField3AddMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid); + resolveField4DeleteMembers (conflictingChange, result, fullMembersByUuid); + resolveField5ModifyMemberRoles (conflictingChange, result, fullMembersByUuid); + resolveField6ModifyProfileKeys (conflictingChange, result, fullMembersByUuid); + resolveField7AddPendingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid); + resolveField8DeletePendingMembers (conflictingChange, result, pendingMembersByUuid); + resolveField9PromotePendingMembers (conflictingChange, result, pendingMembersByUuid); + resolveField10ModifyTitle (groupState, conflictingChange, result); + resolveField11ModifyAvatar (groupState, conflictingChange, result); + resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, result); + resolveField13modifyAttributesAccess (groupState, conflictingChange, result); + resolveField14modifyAttributesAccess (groupState, conflictingChange, result); + + return result; + } + + private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap fullMembersByUuid, HashMap pendingMembersByUuid) { + List newMembersList = conflictingChange.getNewMembersList(); + + for (int i = newMembersList.size() - 1; i >= 0; i--) { + DecryptedMember member = newMembersList.get(i); + + if (fullMembersByUuid.containsKey(member.getUuid())) { + result.removeAddMembers(i); + } else if (pendingMembersByUuid.containsKey(member.getUuid())) { + GroupChange.Actions.AddMemberAction addMemberAction = result.getAddMembersList().get(i); + result.removeAddMembers(i); + result.addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation())); + } + } + } + + private static void resolveField4DeleteMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap fullMembersByUuid) { + List deletedMembersList = conflictingChange.getDeleteMembersList(); + + for (int i = deletedMembersList.size() - 1; i >= 0; i--) { + ByteString member = deletedMembersList.get(i); + + if (!fullMembersByUuid.containsKey(member)) { + result.removeDeleteMembers(i); + } + } + } + + private static void resolveField5ModifyMemberRoles(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap fullMembersByUuid) { + List modifyRolesList = conflictingChange.getModifyMemberRolesList(); + + for (int i = modifyRolesList.size() - 1; i >= 0; i--) { + DecryptedModifyMemberRole modifyRoleAction = modifyRolesList.get(i); + DecryptedMember memberInGroup = fullMembersByUuid.get(modifyRoleAction.getUuid()); + + if (memberInGroup == null || memberInGroup.getRole() == modifyRoleAction.getRole()) { + result.removeModifyMemberRoles(i); + } + } + } + + private static void resolveField6ModifyProfileKeys(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap fullMembersByUuid) { + List modifyProfileKeysList = conflictingChange.getModifiedProfileKeysList(); + + for (int i = modifyProfileKeysList.size() - 1; i >= 0; i--) { + DecryptedMember member = modifyProfileKeysList.get(i); + DecryptedMember memberInGroup = fullMembersByUuid.get(member.getUuid()); + + if (memberInGroup == null || member.getProfileKey().equals(memberInGroup.getProfileKey())) { + result.removeModifyMemberProfileKeys(i); + } + } + } + + private static void resolveField7AddPendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap fullMembersByUuid, HashMap pendingMembersByUuid) { + List newPendingMembersList = conflictingChange.getNewPendingMembersList(); + + for (int i = newPendingMembersList.size() - 1; i >= 0; i--) { + DecryptedPendingMember member = newPendingMembersList.get(i); + + if (fullMembersByUuid.containsKey(member.getUuid()) || pendingMembersByUuid.containsKey(member.getUuid())) { + result.removeAddPendingMembers(i); + } + } + } + + private static void resolveField8DeletePendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap pendingMembersByUuid) { + List deletePendingMembersList = conflictingChange.getDeletePendingMembersList(); + + for (int i = deletePendingMembersList.size() - 1; i >= 0; i--) { + DecryptedPendingMemberRemoval member = deletePendingMembersList.get(i); + + if (!pendingMembersByUuid.containsKey(member.getUuid())) { + result.removeDeletePendingMembers(i); + } + } + } + + private static void resolveField9PromotePendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap pendingMembersByUuid) { + List promotePendingMembersList = conflictingChange.getPromotePendingMembersList(); + + for (int i = promotePendingMembersList.size() - 1; i >= 0; i--) { + ByteString member = promotePendingMembersList.get(i); + + if (!pendingMembersByUuid.containsKey(member)) { + result.removePromotePendingMembers(i); + } + } + } + + private static void resolveField10ModifyTitle(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) { + if (conflictingChange.hasNewTitle() && conflictingChange.getNewTitle().getValue().equals(groupState.getTitle())) { + result.clearModifyTitle(); + } + } + + private static void resolveField11ModifyAvatar(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) { + if (conflictingChange.hasNewAvatar() && conflictingChange.getNewAvatar().getValue().equals(groupState.getAvatar())) { + result.clearModifyAvatar(); + } + } + + private static void resolveField12modifyDisappearingMessagesTimer(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) { + if (conflictingChange.hasNewTimer() && conflictingChange.getNewTimer().getDuration() == groupState.getDisappearingMessagesTimer().getDuration()) { + result.clearModifyDisappearingMessagesTimer(); + } + } + + private static void resolveField13modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) { + if (conflictingChange.getNewAttributeAccess() == groupState.getAccessControl().getAttributes()) { + result.clearModifyAttributesAccess(); + } + } + + private static void resolveField14modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) { + if (conflictingChange.getNewMemberAccess() == groupState.getAccessControl().getMembers()) { + result.clearModifyMemberAccess(); + } + } +} diff --git a/libsignal/service/src/main/proto/DecryptedGroups.proto b/libsignal/service/src/main/proto/DecryptedGroups.proto new file mode 100644 index 0000000000..d8c71b9df3 --- /dev/null +++ b/libsignal/service/src/main/proto/DecryptedGroups.proto @@ -0,0 +1,73 @@ +/** + * Copyright (C) 2019 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +syntax = "proto3"; + +option java_package = "org.signal.storageservice.protos.groups.local"; +option java_multiple_files = true; + +import "Groups.proto"; + +// Decrypted version of Member +// Keep field numbers in step +message DecryptedMember { + bytes uuid = 1; + Member.Role role = 2; + bytes profileKey = 3; + uint32 joinedAtVersion = 5; +} + +message DecryptedPendingMember { + bytes uuid = 1; + Member.Role role = 2; + bytes addedByUuid = 3; + uint64 timestamp = 4; + bytes uuidCipherText = 5; +} + +message DecryptedPendingMemberRemoval { + bytes uuid = 1; + bytes uuidCipherText = 2; +} + +message DecryptedModifyMemberRole { + bytes uuid = 1; + Member.Role role = 2; +} + +// Decrypted version of message Group +// Keep field numbers in step +message DecryptedGroup { + string title = 2; + string avatar = 3; + DisappearingMessagesTimer disappearingMessagesTimer = 4; + AccessControl accessControl = 5; + uint32 version = 6; + repeated DecryptedMember members = 7; + repeated DecryptedPendingMember pendingMembers = 8; +} + +// Decrypted version of message GroupChange.Actions +// Keep field numbers in step +message DecryptedGroupChange { + bytes editor = 1; + uint32 version = 2; + repeated DecryptedMember newMembers = 3; + repeated bytes deleteMembers = 4; + repeated DecryptedModifyMemberRole modifyMemberRoles = 5; + repeated DecryptedMember modifiedProfileKeys = 6; + repeated DecryptedPendingMember newPendingMembers = 7; + repeated DecryptedPendingMemberRemoval deletePendingMembers = 8; + repeated bytes promotePendingMembers = 9; + DecryptedString newTitle = 10; + DecryptedString newAvatar = 11; + DisappearingMessagesTimer newTimer = 12; + AccessControl.AccessRequired newAttributeAccess = 13; + AccessControl.AccessRequired newMemberAccess = 14; +} + +message DecryptedString { + string value = 1; +} diff --git a/libsignal/service/src/main/proto/Groups.proto b/libsignal/service/src/main/proto/Groups.proto new file mode 100644 index 0000000000..47057b56f4 --- /dev/null +++ b/libsignal/service/src/main/proto/Groups.proto @@ -0,0 +1,148 @@ +/** + * Copyright (C) 2019 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +syntax = "proto3"; + +option java_package = "org.signal.storageservice.protos.groups"; +option java_multiple_files = true; + +message AvatarUploadAttributes { + string key = 1; + string credential = 2; + string acl = 3; + string algorithm = 4; + string date = 5; + string policy = 6; + string signature = 7; +} + +message Member { + enum Role { + UNKNOWN = 0; + DEFAULT = 1; + ADMINISTRATOR = 2; + } + + bytes userId = 1; + Role role = 2; + bytes profileKey = 3; + bytes presentation = 4; + uint32 joinedAtVersion = 5; +} + +message PendingMember { + Member member = 1; + bytes addedByUserId = 2; + uint64 timestamp = 3; +} + +message AccessControl { + enum AccessRequired { + UNKNOWN = 0; + ANY = 1; + MEMBER = 2; + ADMINISTRATOR = 3; + } + + AccessRequired attributes = 1; + AccessRequired members = 2; +} + +message Group { + bytes publicKey = 1; + bytes title = 2; + string avatar = 3; + bytes disappearingMessagesTimer = 4; + AccessControl accessControl = 5; + uint32 version = 6; + repeated Member members = 7; + repeated PendingMember pendingMembers = 8; +} + +message GroupChange { + + message Actions { + + message AddMemberAction { + Member added = 1; + } + + message DeleteMemberAction { + bytes deletedUserId = 1; + } + + message ModifyMemberRoleAction { + bytes userId = 1; + Member.Role role = 2; + } + + message ModifyMemberProfileKeyAction { + bytes presentation = 1; + } + + message AddPendingMemberAction { + PendingMember added = 1; + } + + message DeletePendingMemberAction { + bytes deletedUserId = 1; + } + + message PromotePendingMemberAction { + bytes presentation = 1; + } + + message ModifyTitleAction { + bytes title = 1; + } + + message ModifyAvatarAction { + string avatar = 1; + } + + message ModifyDisappearingMessagesTimerAction { + bytes timer = 1; + } + + message ModifyAttributesAccessControlAction { + AccessControl.AccessRequired attributesAccess = 1; + } + + message ModifyMembersAccessControlAction { + AccessControl.AccessRequired membersAccess = 1; + } + + bytes sourceUuid = 1; + uint32 version = 2; + repeated AddMemberAction addMembers = 3; + repeated DeleteMemberAction deleteMembers = 4; + repeated ModifyMemberRoleAction modifyMemberRoles = 5; + repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; + repeated AddPendingMemberAction addPendingMembers = 7; + repeated DeletePendingMemberAction deletePendingMembers = 8; + repeated PromotePendingMemberAction promotePendingMembers = 9; + ModifyTitleAction modifyTitle = 10; + ModifyAvatarAction modifyAvatar = 11; + ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; + ModifyAttributesAccessControlAction modifyAttributesAccess = 13; + ModifyMembersAccessControlAction modifyMemberAccess = 14; + } + + bytes actions = 1; + bytes serverSignature = 2; +} + +message GroupChanges { + message GroupChangeState { + GroupChange groupChange = 1; + Group groupState = 2; + } + + repeated GroupChangeState groupChanges = 1; +} + +message DisappearingMessagesTimer { + uint32 duration = 1; +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/UuidUtilTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/UuidUtilTest.java index 53962bfdfd..63f55f2d34 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/UuidUtilTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/util/UuidUtilTest.java @@ -1,6 +1,9 @@ package org.whispersystems.signalservice.api.util; +import com.google.protobuf.ByteString; + import org.junit.Test; +import org.signal.zkgroup.util.UUIDUtil; import org.whispersystems.libsignal.util.Hex; import java.io.IOException; @@ -46,4 +49,31 @@ public final class UuidUtilTest { assertEquals("b83dfb0b-67f1-41aa-992e-030c167cd011", uuid.toString()); } + + @Test + public void byte_array_compatibility_with_zk_group_uuid_util() { + UUID uuid = UUID.fromString("67dfd496-ea02-4720-b13d-83a462168b1d"); + + UUID result = UUIDUtil.deserialize(UuidUtil.toByteArray(uuid)); + + assertEquals(uuid, result); + } + + @Test + public void byte_string_compatibility_with_zk_group_uuid_util() { + UUID uuid = UUID.fromString("67dfd496-ea02-4720-b13d-83a462168b1d"); + + UUID result = UuidUtil.fromByteString(ByteString.copyFrom(UUIDUtil.serialize(uuid))); + + assertEquals(uuid, result); + } + + @Test + public void byte_string_round_trip() { + UUID uuid = UUID.fromString("67dfd496-ea02-4720-b13d-83a462168b1d"); + + UUID result = UuidUtil.fromByteString(ByteString.copyFrom(UuidUtil.toByteArray(uuid))); + + assertEquals(uuid, result); + } } diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtilTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtilTest.java new file mode 100644 index 0000000000..5075fb10f9 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtilTest.java @@ -0,0 +1,149 @@ +package org.whispersystems.signalservice.internal.groupsv2; + +import org.junit.Test; +import org.signal.storageservice.protos.groups.GroupChange; + +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class GroupChangeUtilTest { + + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would easily affect {@link GroupChangeUtil}'s ability to detect empty change states and resolve conflicts. + */ + @Test + public void ensure_GroupChangeUtil_knows_about_all_fields_of_GroupChange_Actions() { + int maxFieldFound = Stream.of(GroupChange.Actions.class.getFields()) + .filter(f -> f.getType() == int.class) + .mapToInt(f -> { + try { + return (int) f.get(null); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + }) + .max() + .orElse(0); + + assertEquals("GroupChangeUtil and its tests need updating to account for new fields on " + GroupChange.Actions.class.getName(), + GroupChangeUtil.CHANGE_ACTION_MAX_FIELD, maxFieldFound); + } + + @Test + public void empty_change_set() { + assertTrue(GroupChangeUtil.changeIsEmpty(GroupChange.Actions.newBuilder().build())); + } + + @Test + public void not_empty_with_add_member_field_3() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_delete_member_field_4() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_member_roles_field_5() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_profile_keys_field_6() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_add_pending_members_field_7() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_delete_pending_members_field_8() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_promote_delete_pending_members_field_9() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_title_field_10() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .setModifyTitle(GroupChange.Actions.ModifyTitleAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_avatar_field_11() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_disappearing_message_timer_field_12() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_attributes_field_13() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_member_access_field_14() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction.newBuilder().getDefaultInstanceForType()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtil_resolveConflict_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtil_resolveConflict_Test.java new file mode 100644 index 0000000000..0c2e41b525 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/internal/groupsv2/GroupChangeUtil_resolveConflict_Test.java @@ -0,0 +1,559 @@ +package org.whispersystems.signalservice.internal.groupsv2; + +import com.google.protobuf.ByteString; + +import org.junit.Test; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.DisappearingMessagesTimer; +import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.PendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; +import org.signal.storageservice.protos.groups.local.DecryptedString; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.profiles.ProfileKey; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class GroupChangeUtil_resolveConflict_Test { + + @Test + public void empty_actions() { + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(DecryptedGroup.newBuilder().build(), + DecryptedGroupChange.newBuilder().build(), + GroupChange.Actions.newBuilder().build()) + .build(); + + assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); + } + + @Test + public void field_3__changes_to_add_existing_members_are_excluded() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + ProfileKey profileKey2 = randomProfileKey(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member1)) + .addMembers(member(member3)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addNewMembers(member(member1)) + .addNewMembers(member(member2)) + .addNewMembers(member(member3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder().setAdded(encryptedMember(member1, randomProfileKey()))) + .addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder().setAdded(encryptedMember(member2, profileKey2))) + .addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder().setAdded(encryptedMember(member3, randomProfileKey()))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder().setAdded(encryptedMember(member2, profileKey2))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_4__changes_to_remove_missing_members_are_excluded() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member2)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addDeleteMembers(UuidUtil.toByteString(member1)) + .addDeleteMembers(UuidUtil.toByteString(member2)) + .addDeleteMembers(UuidUtil.toByteString(member3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder().setDeletedUserId(encrypt(member1))) + .addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder().setDeletedUserId(encrypt(member2))) + .addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder().setDeletedUserId(encrypt(member3))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder().setDeletedUserId(encrypt(member2))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_5__role_change_is_preserved() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(admin(member1)) + .addMembers(member(member2)) + .addMembers(member(member3)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addModifyMemberRoles(demoteAdmin(member1)) + .addModifyMemberRoles(promoteAdmin(member2)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(encrypt(member1)).setRole(Member.Role.DEFAULT)) + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(encrypt(member2)).setRole(Member.Role.ADMINISTRATOR)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertEquals(change, resolvedActions); + } + + @Test + public void field_5__unnecessary_role_changes_removed() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + UUID memberNotInGroup = UUID.randomUUID(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(admin(member1)) + .addMembers(member(member2)) + .addMembers(member(member3)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addModifyMemberRoles(promoteAdmin(member1)) + .addModifyMemberRoles(promoteAdmin(member2)) + .addModifyMemberRoles(demoteAdmin(member3)) + .addModifyMemberRoles(promoteAdmin(memberNotInGroup)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(encrypt(member1)).setRole(Member.Role.ADMINISTRATOR)) + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(encrypt(member2)).setRole(Member.Role.ADMINISTRATOR)) + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(encrypt(member3)).setRole(Member.Role.DEFAULT)) + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(encrypt(memberNotInGroup)).setRole(Member.Role.ADMINISTRATOR)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().setUserId(encrypt(member2)).setRole(Member.Role.ADMINISTRATOR)) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_6__profile_key_changes() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + UUID memberNotInGroup = UUID.randomUUID(); + ProfileKey profileKey1 = randomProfileKey(); + ProfileKey profileKey2 = randomProfileKey(); + ProfileKey profileKey3 = randomProfileKey(); + ProfileKey profileKey4 = randomProfileKey(); + ProfileKey profileKey2b = randomProfileKey(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member1, profileKey1)) + .addMembers(member(member2, profileKey2)) + .addMembers(member(member3, profileKey3)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addModifiedProfileKeys(member(member1, profileKey1)) + .addModifiedProfileKeys(member(member2, profileKey2b)) + .addModifiedProfileKeys(member(member3, profileKey3)) + .addModifiedProfileKeys(member(memberNotInGroup, profileKey4)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder().setPresentation(presentation(member1, profileKey1))) + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder().setPresentation(presentation(member2, profileKey2b))) + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder().setPresentation(presentation(member3, profileKey3))) + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder().setPresentation(presentation(memberNotInGroup, profileKey4))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder().setPresentation(presentation(member2, profileKey2b))) + .build(); + + assertEquals(expected, resolvedActions); + } + + @Test + public void field_7__add_pending_members() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + ProfileKey profileKey2 = randomProfileKey(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member1)) + .addPendingMembers(pendingMember(member3)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addNewPendingMembers(pendingMember(member1)) + .addNewPendingMembers(pendingMember(member2)) + .addNewPendingMembers(pendingMember(member3)) + .build(); + + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder().setAdded(PendingMember.newBuilder().setMember(encryptedMember(member1, randomProfileKey())))) + .addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder().setAdded(PendingMember.newBuilder().setMember(encryptedMember(member2, profileKey2)))) + .addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder().setAdded(PendingMember.newBuilder().setMember(encryptedMember(member3, randomProfileKey())))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder().setAdded(PendingMember.newBuilder().setMember(encryptedMember(member2, profileKey2)))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_8__delete_pending_members() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member1)) + .addPendingMembers(pendingMember(member2)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addDeletePendingMembers(pendingMemberRemoval(member1)) + .addDeletePendingMembers(pendingMemberRemoval(member2)) + .addDeletePendingMembers(pendingMemberRemoval(member3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder().setDeletedUserId(encrypt(member1))) + .addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder().setDeletedUserId(encrypt(member2))) + .addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder().setDeletedUserId(encrypt(member3))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder().setDeletedUserId(encrypt(member2))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_9__promote_pending_members() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + ProfileKey profileKey2 = randomProfileKey(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member1)) + .addPendingMembers(pendingMember(member2)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addPromotePendingMembers(UuidUtil.toByteString(member1)) + .addPromotePendingMembers(UuidUtil.toByteString(member2)) + .addPromotePendingMembers(UuidUtil.toByteString(member3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member1, randomProfileKey()))) + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member2, profileKey2))) + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member3, randomProfileKey()))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member2, profileKey2))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_3_to_9__add_of_pending_member_converted_to_a_promote() { + UUID member1 = UUID.randomUUID(); + ProfileKey profileKey1 = randomProfileKey(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addPendingMembers(pendingMember(member1)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addNewMembers(member(member1)) + .build(); + + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder().setAdded(encryptedMember(member1, profileKey1))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member1, profileKey1))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_10__title_change_is_preserved() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setTitle("Existing title") + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewTitle(DecryptedString.newBuilder().setValue("New title").build()) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyTitle(GroupChange.Actions.ModifyTitleAction.newBuilder().setTitle(ByteString.copyFrom("New title encrypted".getBytes()))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertEquals(change, resolvedActions); + } + + @Test + public void field_10__no_title_change_is_removed() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setTitle("Existing title") + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewTitle(DecryptedString.newBuilder().setValue("Existing title").build()) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyTitle(GroupChange.Actions.ModifyTitleAction.newBuilder().setTitle(ByteString.copyFrom("Existing title encrypted".getBytes()))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); + } + + @Test + public void field_11__avatar_change_is_preserved() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setAvatar("Existing avatar") + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewAvatar(DecryptedString.newBuilder().setValue("New avatar").build()) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar("New avatar possibly encrypted")) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertEquals(change, resolvedActions); + } + + @Test + public void field_11__no_avatar_change_is_removed() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setAvatar("Existing avatar") + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewAvatar(DecryptedString.newBuilder().setValue("Existing avatar").build()) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar("Existing avatar possibly encrypted")) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); + } + + @Test + public void field_12__timer_change_is_preserved() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setDisappearingMessagesTimer(DisappearingMessagesTimer.newBuilder().setDuration(123)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewTimer(DisappearingMessagesTimer.newBuilder().setDuration(456)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.newBuilder().setTimer(ByteString.EMPTY)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertEquals(change, resolvedActions); + } + + @Test + public void field_12__no_timer_change_is_removed() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setDisappearingMessagesTimer(DisappearingMessagesTimer.newBuilder().setDuration(123)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewTimer(DisappearingMessagesTimer.newBuilder().setDuration(123)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.newBuilder().setTimer(ByteString.EMPTY)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); + } + + @Test + public void field_13__attribute_access_change_is_preserved() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setAccessControl(AccessControl.newBuilder().setAttributes(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewAttributeAccess(AccessControl.AccessRequired.MEMBER) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction.newBuilder().setAttributesAccess(AccessControl.AccessRequired.MEMBER)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertEquals(change, resolvedActions); + } + + @Test + public void field_13__no_attribute_access_change_is_removed() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setAccessControl(AccessControl.newBuilder().setAttributes(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewAttributeAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction.newBuilder().setAttributesAccess(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); + } + + @Test + public void field_14__membership_access_change_is_preserved() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewMemberAccess(AccessControl.AccessRequired.MEMBER) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction.newBuilder().setMembersAccess(AccessControl.AccessRequired.MEMBER)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertEquals(change, resolvedActions); + } + + @Test + public void field_14__no_membership_access_change_is_removed() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction.newBuilder().setMembersAccess(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); + } + + private static ProfileKey randomProfileKey() { + byte[] contents = new byte[32]; + new SecureRandom().nextBytes(contents); + try { + return new ProfileKey(contents); + } catch (InvalidInputException e) { + throw new AssertionError(); + } + } + + /** + * Emulates encryption by creating a unique {@link ByteString} that won't equal a byte string created from the {@link UUID}. + */ + private static ByteString encrypt(UUID uuid) { + byte[] uuidBytes = UuidUtil.toByteArray(uuid); + return ByteString.copyFrom(Arrays.copyOf(uuidBytes, uuidBytes.length + 1)); + } + + /** + * Emulates a presentation by concatenating the uuid and profile key which makes it suitable for + * equality assertions in these tests. + */ + private static ByteString presentation(UUID uuid, ProfileKey profileKey) { + byte[] uuidBytes = UuidUtil.toByteArray(uuid); + byte[] profileKeyBytes = profileKey.serialize(); + byte[] concat = new byte[uuidBytes.length + profileKeyBytes.length]; + + System.arraycopy(uuidBytes, 0, concat, 0, uuidBytes.length); + System.arraycopy(profileKeyBytes, 0, concat, uuidBytes.length, profileKeyBytes.length); + + return ByteString.copyFrom(concat); + } + + private static DecryptedModifyMemberRole promoteAdmin(UUID member) { + return DecryptedModifyMemberRole.newBuilder() + .setUuid(UuidUtil.toByteString(member)) + .setRole(Member.Role.ADMINISTRATOR) + .build(); + } + + private static DecryptedModifyMemberRole demoteAdmin(UUID member) { + return DecryptedModifyMemberRole.newBuilder() + .setUuid(UuidUtil.toByteString(member)) + .setRole(Member.Role.DEFAULT) + .build(); + } + + private Member encryptedMember(UUID uuid, ProfileKey profileKey) { + return Member.newBuilder() + .setPresentation(presentation(uuid, profileKey)) + .build(); + } + + private static DecryptedMember member(UUID uuid) { + return DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setRole(Member.Role.DEFAULT) + .build(); + } + + private static DecryptedPendingMemberRemoval pendingMemberRemoval(UUID uuid) { + return DecryptedPendingMemberRemoval.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .build(); + } + + private static DecryptedPendingMember pendingMember(UUID uuid) { + return DecryptedPendingMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setRole(Member.Role.DEFAULT) + .build(); + } + + private static DecryptedMember member(UUID uuid, ProfileKey profileKey) { + return DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setRole(Member.Role.DEFAULT) + .setProfileKey(ByteString.copyFrom(profileKey.serialize())) + .build(); + } + + private static DecryptedMember admin(UUID uuid) { + return DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setRole(Member.Role.ADMINISTRATOR) + .build(); + } +} \ No newline at end of file