Add group terminate support.

This commit is contained in:
Cody Henthorne
2026-03-19 16:10:26 -04:00
parent 0896718e5c
commit a0c0acb8fc
130 changed files with 1312 additions and 146 deletions

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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)
}
}
}

View File

@@ -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.
*/