mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 16:19:33 +01:00
Add group terminate support.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
|
||||
class GroupSettingsStateTest {
|
||||
|
||||
private val v2GroupId = GroupId.v2(org.signal.libsignal.zkgroup.groups.GroupMasterKey(ByteArray(32)))
|
||||
private val v1GroupId = GroupId.v1(ByteArray(16))
|
||||
|
||||
private fun createState(
|
||||
groupId: GroupId = v2GroupId,
|
||||
isActive: Boolean = true,
|
||||
isSelfAdmin: Boolean = true,
|
||||
canLeave: Boolean = true
|
||||
): SpecificSettingsState.GroupSettingsState {
|
||||
return SpecificSettingsState.GroupSettingsState(
|
||||
groupId = groupId,
|
||||
isActive = isActive,
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
canLeave = canLeave
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canEndGroup is true when active v2 group and self is admin`() {
|
||||
assertTrue(createState().canEndGroup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canEndGroup is false when group is not active`() {
|
||||
assertFalse(createState(isActive = false).canEndGroup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canEndGroup is false when self is not admin`() {
|
||||
assertFalse(createState(isSelfAdmin = false).canEndGroup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canEndGroup is false for v1 group`() {
|
||||
assertFalse(createState(groupId = v1GroupId).canEndGroup)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class PermissionsSettingsViewModelTest {
|
||||
): PermissionsSettingsViewModel {
|
||||
val liveGroup = mockk<LiveGroup> {
|
||||
every { isSelfAdmin } returns MutableLiveData(false)
|
||||
every { isActive } returns MutableLiveData(true)
|
||||
every { membershipAdditionAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS)
|
||||
every { attributesAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS)
|
||||
every { isAnnouncementGroup } returns MutableLiveData(false)
|
||||
|
||||
@@ -107,6 +107,10 @@ class GroupChangeData(private val revision: Int, private val groupOperations: Gr
|
||||
fun changeMemberLabelAccess(access: AccessControl.AccessRequired) {
|
||||
actionsBuilder.modifyMemberLabelAccess = GroupChange.Actions.ModifyMemberLabelAccessControlAction(memberLabelAccess = access)
|
||||
}
|
||||
|
||||
fun terminateGroup() {
|
||||
actionsBuilder.terminate_group = GroupChange.Actions.TerminateGroupAction()
|
||||
}
|
||||
}
|
||||
|
||||
class GroupStateTestData(private val masterKey: GroupMasterKey, private val groupOperations: GroupsV2Operations.GroupOperations? = null) {
|
||||
@@ -134,9 +138,10 @@ class GroupStateTestData(private val masterKey: GroupMasterKey, private val grou
|
||||
requestingMembers: List<DecryptedRequestingMember> = emptyList(),
|
||||
inviteLinkPassword: ByteArray = ByteArray(0),
|
||||
disappearingMessageTimer: DecryptedTimer = DecryptedTimer(),
|
||||
isPlaceholderGroup: Boolean = false
|
||||
isPlaceholderGroup: Boolean = false,
|
||||
terminated: Boolean = false
|
||||
) {
|
||||
localState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, isPlaceholderGroup)
|
||||
localState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, isPlaceholderGroup, terminated)
|
||||
groupRecord = groupRecord(masterKey, localState!!, active = active)
|
||||
}
|
||||
|
||||
@@ -151,9 +156,10 @@ class GroupStateTestData(private val masterKey: GroupMasterKey, private val grou
|
||||
pendingMembers: List<DecryptedPendingMember> = extendGroup?.pendingMembers ?: emptyList(),
|
||||
requestingMembers: List<DecryptedRequestingMember> = extendGroup?.requestingMembers ?: emptyList(),
|
||||
inviteLinkPassword: ByteArray = extendGroup?.inviteLinkPassword?.toByteArray() ?: ByteArray(0),
|
||||
disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer()
|
||||
disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer(),
|
||||
terminated: Boolean = extendGroup?.terminated ?: false
|
||||
) {
|
||||
serverState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer)
|
||||
serverState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, terminated = terminated)
|
||||
}
|
||||
|
||||
fun changeSet(init: ChangeSet.() -> Unit) {
|
||||
@@ -200,6 +206,7 @@ fun groupRecord(
|
||||
avatarKey,
|
||||
avatarContentType,
|
||||
active,
|
||||
terminatedBy = if (decryptedGroup.terminated) -1L else 0L,
|
||||
avatarDigest,
|
||||
mms,
|
||||
masterKey.serialize(),
|
||||
@@ -223,7 +230,8 @@ fun decryptedGroup(
|
||||
requestingMembers: List<DecryptedRequestingMember> = emptyList(),
|
||||
inviteLinkPassword: ByteArray = ByteArray(0),
|
||||
disappearingMessageTimer: DecryptedTimer = DecryptedTimer(),
|
||||
isPlaceholderGroup: Boolean = false
|
||||
isPlaceholderGroup: Boolean = false,
|
||||
terminated: Boolean = false
|
||||
): DecryptedGroup {
|
||||
return DecryptedGroup(
|
||||
accessControl = accessControl,
|
||||
@@ -237,6 +245,7 @@ fun decryptedGroup(
|
||||
members = members,
|
||||
pendingMembers = pendingMembers,
|
||||
requestingMembers = requestingMembers,
|
||||
isPlaceholderGroup = isPlaceholderGroup
|
||||
isPlaceholderGroup = isPlaceholderGroup,
|
||||
terminated = terminated
|
||||
)
|
||||
}
|
||||
|
||||
@@ -310,4 +310,30 @@ class GroupManagerV2Test_edit {
|
||||
assertThat(other2.labelString, "Other2's label text is preserved").isEqualTo("Bar")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when admin terminates group, the group state is updated with terminated flag`() {
|
||||
given {
|
||||
localState(
|
||||
revision = 5,
|
||||
members = listOf(
|
||||
member(selfAci, role = Member.Role.ADMINISTRATOR),
|
||||
member(otherAci)
|
||||
)
|
||||
)
|
||||
groupChange(6) {
|
||||
source(selfAci)
|
||||
terminateGroup()
|
||||
}
|
||||
}
|
||||
|
||||
editGroup {
|
||||
terminateGroup()
|
||||
}
|
||||
|
||||
then { patchedGroup ->
|
||||
assertThat(patchedGroup.revision, "Revision updated by one").isEqualTo(6)
|
||||
assertThat(patchedGroup.terminated, "Group is terminated").isEqualTo(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
|
||||
import org.thoughtcrime.securesms.testutil.SystemOutLogger
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
@@ -1050,6 +1051,152 @@ class GroupsV2StateProcessorTest {
|
||||
assertThat(result.updateStatus, "inactive local is still updated given same revision from server").isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when P2P change terminates group with known editor, then setTerminatedBy is called`() {
|
||||
val adminAci: ACI = ACI.from(UUID.randomUUID())
|
||||
val adminRecipientId = RecipientId.from(200)
|
||||
|
||||
given {
|
||||
localState(
|
||||
revision = 5,
|
||||
members = selfAndOthers
|
||||
)
|
||||
expectTableUpdate = true
|
||||
}
|
||||
|
||||
every { recipientTable.getAndPossiblyMerge(adminAci, null) } returns adminRecipientId
|
||||
justRun { groupTable.setTerminatedBy(groupId, adminRecipientId) }
|
||||
|
||||
val signedChange = DecryptedGroupChange(
|
||||
revision = 6,
|
||||
editorServiceIdBytes = adminAci.toByteString(),
|
||||
terminateGroup = true
|
||||
)
|
||||
|
||||
val result = processor.updateLocalGroupToRevision(
|
||||
targetRevision = 6,
|
||||
timestamp = 0,
|
||||
signedGroupChange = signedChange,
|
||||
serverGuid = UUID.randomUUID().toString()
|
||||
)
|
||||
|
||||
assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED)
|
||||
assertThat(result.latestServer)
|
||||
.isNotNull()
|
||||
.transform {
|
||||
assertThat(it.terminated, "group should be terminated").isEqualTo(true)
|
||||
}
|
||||
|
||||
verify { groupTable.setTerminatedBy(groupId, adminRecipientId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when P2P change terminates group without editor, then setTerminatedBy is not called`() {
|
||||
given {
|
||||
localState(
|
||||
revision = 5,
|
||||
members = selfAndOthers
|
||||
)
|
||||
expectTableUpdate = true
|
||||
}
|
||||
|
||||
val signedChange = DecryptedGroupChange(
|
||||
revision = 6,
|
||||
terminateGroup = true
|
||||
)
|
||||
|
||||
val result = processor.updateLocalGroupToRevision(
|
||||
targetRevision = 6,
|
||||
timestamp = 0,
|
||||
signedGroupChange = signedChange,
|
||||
serverGuid = UUID.randomUUID().toString()
|
||||
)
|
||||
|
||||
assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED)
|
||||
assertThat(result.latestServer)
|
||||
.isNotNull()
|
||||
.transform {
|
||||
assertThat(it.terminated, "group should be terminated").isEqualTo(true)
|
||||
}
|
||||
|
||||
verify(exactly = 0) { groupTable.setTerminatedBy(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when force sanity update finds terminated group, then setTerminatedBy is not called because reconstructed change has no editor`() {
|
||||
given {
|
||||
localState(
|
||||
revision = 10,
|
||||
title = "Title",
|
||||
members = selfAndOthers
|
||||
)
|
||||
serverState(
|
||||
revision = 11,
|
||||
title = "Title",
|
||||
members = selfAndOthers,
|
||||
terminated = true
|
||||
)
|
||||
expectTableUpdate = true
|
||||
}
|
||||
|
||||
val result = processor.forceSanityUpdateFromServer(0)
|
||||
|
||||
assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED)
|
||||
assertThat(result.latestServer)
|
||||
.isNotNull()
|
||||
.transform {
|
||||
assertThat(it.terminated, "group should be terminated").isEqualTo(true)
|
||||
}
|
||||
|
||||
verify(exactly = 0) { groupTable.setTerminatedBy(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when group is already terminated, then force sanity update returns consistent`() {
|
||||
given {
|
||||
localState(
|
||||
revision = 10,
|
||||
members = selfAndOthers,
|
||||
terminated = true
|
||||
)
|
||||
}
|
||||
|
||||
val result = processor.forceSanityUpdateFromServer(0)
|
||||
|
||||
assertThat(result.updateStatus, "already terminated group should not update")
|
||||
.isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when P2P change is received for terminated group, then P2P change is not applied`() {
|
||||
given {
|
||||
localState(
|
||||
revision = 5,
|
||||
members = selfAndOthers,
|
||||
terminated = true
|
||||
)
|
||||
changeSet {
|
||||
}
|
||||
apiCallParameters(requestedRevision = 5, includeFirst = false)
|
||||
joinedAtRevision = 0
|
||||
}
|
||||
|
||||
val signedChange = DecryptedGroupChange(
|
||||
revision = 6,
|
||||
newTitle = DecryptedString("New Title")
|
||||
)
|
||||
|
||||
val result = processor.updateLocalGroupToRevision(
|
||||
targetRevision = 6,
|
||||
timestamp = 0,
|
||||
signedGroupChange = signedChange,
|
||||
serverGuid = UUID.randomUUID().toString()
|
||||
)
|
||||
|
||||
assertThat(result.updateStatus, "terminated group should not accept P2P changes")
|
||||
.isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD)
|
||||
}
|
||||
|
||||
/**
|
||||
* If we get a 500 back from the service we handle it gracefully.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user