// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import path from 'path'; import { tmpdir } from 'os'; import { createReadStream } from 'fs'; import { mkdtemp, rm } from 'fs/promises'; import { v4 as generateGuid } from 'uuid'; import { assert } from 'chai'; import { pick, sortBy } from 'lodash'; import Data from '../../sql/Client'; import { backupsService } from '../../services/backups'; import { generateAci, generatePni } from '../../types/ServiceId'; import { SignalService as Proto } from '../../protobuf'; import type { MessageAttributesType } from '../../model-types'; import type { GroupV2ChangeType } from '../../groups'; import { getRandomBytes } from '../../Crypto'; import * as Bytes from '../../Bytes'; import { loadCallsHistory } from '../../services/callHistoryLoader'; import { strictAssert } from '../../util/assert'; import { DurationInSeconds } from '../../util/durations'; // Note: this should be kept up to date with GroupV2Change.stories.tsx, to // maintain the comprehensive set of GroupV2 notifications we need to handle const AccessControlEnum = Proto.AccessControl.AccessRequired; const RoleEnum = Proto.Member.Role; const EXPIRATION_TIMER_FLAG = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; const OUR_ACI = generateAci(); const OUR_PNI = generatePni(); const CONTACT_A = generateAci(); const CONTACT_A_PNI = generatePni(); const CONTACT_B = generateAci(); const CONTACT_C = generateAci(); const ADMIN_A = generateAci(); const INVITEE_A = generateAci(); const GROUP_ID = Bytes.toBase64(getRandomBytes(32)); const MASTER_KEY = Bytes.toBase64(getRandomBytes(32)); const PROFILEKEY = getRandomBytes(32); // We need to eliminate fields that won't stay stable through import/export function sortAndNormalize( messages: Array ): Array> { return sortBy(messages, 'sent_at').map(message => pick( message, 'droppedGV2MemberIds', 'expirationTimerUpdate', 'groupMigration', 'groupV2Change', 'invitedGV2Members', 'sent_at', 'timestamp', 'type' ) ); } async function symmetricRoundtripHarness( messages: Array ) { return asymmetricRoundtripHarness(messages, messages); } async function asymmetricRoundtripHarness( before: Array, after: Array ) { const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-')); try { const targetOutputFile = path.join(outDir, 'backup.bin'); await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI }); await backupsService.exportToDisk(targetOutputFile); await clearData(); await backupsService.importBackup(() => createReadStream(targetOutputFile)); const messagesFromDatabase = await Data._getAllMessages(); const expected = sortAndNormalize(after); const actual = sortAndNormalize(messagesFromDatabase); assert.deepEqual(expected, actual); } finally { await rm(outDir, { recursive: true }); } } async function clearData() { await Data._removeAllMessages(); await Data._removeAllConversations(); await Data.removeAllItems(); window.storage.reset(); window.ConversationController.reset(); await setupBasics(); } async function setupBasics() { await window.storage.put('uuid_id', `${OUR_ACI}.2`); await window.storage.put('pni', OUR_PNI); await window.storage.put('masterKey', MASTER_KEY); await window.storage.put('profileKey', PROFILEKEY); await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', { pni: OUR_PNI, systemGivenName: 'ME', profileKey: Bytes.toBase64(PROFILEKEY), }); } let counter = 0; function createMessage( change: GroupV2ChangeType, { disableIncrement }: { disableIncrement: boolean } = { disableIncrement: false, } ): MessageAttributesType { const groupConversation = window.ConversationController.get(GROUP_ID); strictAssert(groupConversation, 'The group conversation must be created!'); if (!disableIncrement) { counter += 1; } return { conversationId: groupConversation.id, groupV2Change: change, id: generateGuid(), received_at: counter, sent_at: counter, timestamp: counter, type: 'group-v2-change', }; } describe('backup/groupv2/notifications', () => { beforeEach(async () => { await Data._removeAllMessages(); await Data._removeAllConversations(); window.storage.reset(); await setupBasics(); await window.ConversationController.getOrCreateAndWait( CONTACT_A, 'private', { pni: CONTACT_A_PNI, systemGivenName: 'CONTACT_A' } ); await window.ConversationController.getOrCreateAndWait( CONTACT_B, 'private', { systemGivenName: 'CONTACT_B' } ); await window.ConversationController.getOrCreateAndWait( CONTACT_C, 'private', { systemGivenName: 'CONTACT_C' } ); await window.ConversationController.getOrCreateAndWait(ADMIN_A, 'private', { systemGivenName: 'ADMIN_A', }); await window.ConversationController.getOrCreateAndWait( INVITEE_A, 'private', { systemGivenName: 'INVITEE_A', } ); await window.ConversationController.getOrCreateAndWait(GROUP_ID, 'group', { groupVersion: 2, masterKey: Bytes.toBase64(getRandomBytes(32)), name: 'Rock Enthusiasts', }); await loadCallsHistory(); window.Events = { ...window.Events, getTypingIndicatorSetting: () => false, getLinkPreviewSetting: () => false, }; }); describe('roundtrips given groupv2 notifications with', () => { it('Multiple items', async () => { const messages: Array = [ createMessage({ from: CONTACT_A, details: [ { type: 'title', newTitle: 'Saturday Running', }, { type: 'avatar', removed: false, }, { type: 'description', description: 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', }, { type: 'member-add', aci: OUR_ACI, }, { type: 'description', description: 'Another description', }, { type: 'member-privilege', aci: OUR_ACI, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('Create items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'create', }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'create', }, ], }), createMessage({ details: [ { type: 'create', }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('Title items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'title', newTitle: 'Saturday Running', }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'title', newTitle: 'Saturday Running', }, ], }), createMessage({ details: [ { type: 'title', newTitle: 'Saturday Running', }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'title', }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'title', }, ], }), createMessage({ details: [ { type: 'title', }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('Avatar items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'avatar', removed: false, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'avatar', removed: false, }, ], }), createMessage({ details: [ { type: 'avatar', removed: false, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'avatar', removed: true, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'avatar', removed: true, }, ], }), createMessage({ details: [ { type: 'avatar', removed: true, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('AccessAttributes items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'access-attributes', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'access-attributes', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ details: [ { type: 'access-attributes', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'access-attributes', newPrivilege: AccessControlEnum.MEMBER, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'access-attributes', newPrivilege: AccessControlEnum.MEMBER, }, ], }), createMessage({ details: [ { type: 'access-attributes', newPrivilege: AccessControlEnum.MEMBER, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('AccessMembers items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'access-members', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'access-members', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ details: [ { type: 'access-members', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'access-members', newPrivilege: AccessControlEnum.MEMBER, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'access-members', newPrivilege: AccessControlEnum.MEMBER, }, ], }), createMessage({ details: [ { type: 'access-members', newPrivilege: AccessControlEnum.MEMBER, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('AccessInviteLink items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'access-invite-link', newPrivilege: AccessControlEnum.ANY, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'access-invite-link', newPrivilege: AccessControlEnum.ANY, }, ], }), createMessage({ details: [ { type: 'access-invite-link', newPrivilege: AccessControlEnum.ANY, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'access-invite-link', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'access-invite-link', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ details: [ { type: 'access-invite-link', newPrivilege: AccessControlEnum.ADMINISTRATOR, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('MemberAdd items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'member-add', aci: OUR_ACI, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-add', aci: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'member-add', aci: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'member-add', aci: CONTACT_A, }, ], }), createMessage({ from: CONTACT_B, details: [ { type: 'member-add', aci: CONTACT_A, }, ], }), createMessage({ details: [ { type: 'member-add', aci: CONTACT_A, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('MemberAddFromInvited items', async () => { const messages: Array = [ // the strings where someone added you - shown like a normal add createMessage({ from: CONTACT_A, details: [ { type: 'member-add-from-invite', aci: OUR_ACI, inviter: CONTACT_B, }, ], }), createMessage({ details: [ { type: 'member-add-from-invite', aci: OUR_ACI, inviter: CONTACT_A, }, ], }), // the rest of the 'someone added someone else' checks */ createMessage({ from: OUR_ACI, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, inviter: CONTACT_B, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-add-from-invite', aci: CONTACT_B, inviter: CONTACT_C, }, ], }), createMessage({ details: [ { type: 'member-add-from-invite', aci: CONTACT_A, inviter: CONTACT_B, }, ], }), // in all of these we know the user has accepted the invite createMessage({ from: OUR_ACI, details: [ { type: 'member-add-from-invite', aci: OUR_ACI, inviter: CONTACT_A, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'member-add-from-invite', aci: OUR_ACI, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, inviter: OUR_ACI, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, inviter: CONTACT_B, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, }, ], }), // ACI accepts PNI invite (X joined the group) // These don't roundtrip; the PNI from is replaced with ACI. See next test below. // createMessage({ // from: OUR_PNI, // details: [ // { // type: 'member-add-from-invite', // aci: OUR_ACI, // pni: OUR_PNI, // inviter: CONTACT_B, // }, // ], // }), // createMessage({ // from: OUR_PNI, // details: [ // { // type: 'member-add-from-invite', // aci: OUR_ACI, // pni: OUR_PNI, // }, // ], // }), // createMessage({ // from: CONTACT_A_PNI, // details: [ // { // type: 'member-add-from-invite', // aci: CONTACT_A, // pni: CONTACT_A_PNI, // }, // ], // }), // ACI accepts PNI invite, the old way (X added X to group) // These don't roundtrip; the PNI is replaced with ACI. See next test below. // createMessage({ // from: OUR_PNI, // details: [ // { // type: 'member-add-from-invite', // aci: OUR_ACI, // inviter: CONTACT_B, // }, // ], // }), // createMessage({ // from: OUR_PNI, // details: [ // { // type: 'member-add-from-invite', // aci: OUR_ACI, // }, // ], // }), // createMessage({ // from: CONTACT_A_PNI, // details: [ // { // type: 'member-add-from-invite', // aci: CONTACT_A, // }, // ], // }), ]; await symmetricRoundtripHarness(messages); }); it('MemberAddFromInvited items', async () => { const firstBefore = createMessage({ from: OUR_PNI, details: [ { type: 'member-add-from-invite', aci: OUR_ACI, inviter: CONTACT_B, }, ], }); const firstAfter = createMessage( { from: OUR_ACI, details: [ { type: 'member-add-from-invite', aci: OUR_ACI, inviter: CONTACT_B, }, ], }, { disableIncrement: true } ); const secondBefore = createMessage({ from: OUR_PNI, details: [ { type: 'member-add-from-invite', aci: OUR_ACI, }, ], }); const secondAfter = createMessage( { from: OUR_ACI, details: [ { type: 'member-add-from-invite', aci: OUR_ACI, }, ], }, { disableIncrement: true } ); const thirdBefore = createMessage({ from: CONTACT_A_PNI, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, }, ], }); const thirdAfter = createMessage( { from: CONTACT_A, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, }, ], }, { disableIncrement: true } ); const fourthBefore = createMessage({ from: CONTACT_A_PNI, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, pni: CONTACT_A_PNI, }, ], }); const fourthAfter = createMessage( { from: CONTACT_A, details: [ { type: 'member-add-from-invite', aci: CONTACT_A, }, ], }, { disableIncrement: true } ); const before = [firstBefore, secondBefore, thirdBefore, fourthBefore]; const after = [firstAfter, secondAfter, thirdAfter, fourthAfter]; await asymmetricRoundtripHarness(before, after); }); it('MemberAddFromLink items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'member-add-from-link', aci: OUR_ACI, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-add-from-link', aci: CONTACT_A, }, ], }), // This doesn't roundtrip because if people join via link, they do it themselves. // See the next test. // createMessage({ // details: [ // { // type: 'member-add-from-link', // aci: CONTACT_A, // }, // ], // }), ]; await symmetricRoundtripHarness(messages); }); it('MemberAddFromLink items asymmetric', async () => { const before: Array = [ createMessage({ details: [ { type: 'member-add-from-link', aci: CONTACT_A, }, ], }), ]; const after: Array = [ createMessage( { from: CONTACT_A, details: [ { type: 'member-add-from-link', aci: CONTACT_A, }, ], }, { disableIncrement: true } ), ]; await asymmetricRoundtripHarness(before, after); }); it('MemberAddFromAdminApproval items', async () => { const messages: Array = [ createMessage({ from: ADMIN_A, details: [ { type: 'member-add-from-admin-approval', aci: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'member-add-from-admin-approval', aci: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'member-add-from-admin-approval', aci: CONTACT_A, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'member-add-from-admin-approval', aci: CONTACT_A, }, ], }), createMessage({ details: [ { type: 'member-add-from-admin-approval', aci: CONTACT_A, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('MemberRemove items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'member-remove', aci: OUR_ACI, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-remove', aci: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'member-remove', aci: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'member-remove', aci: CONTACT_A, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-remove', aci: CONTACT_A, }, ], }), createMessage({ from: CONTACT_B, details: [ { type: 'member-remove', aci: CONTACT_A, }, ], }), createMessage({ details: [ { type: 'member-remove', aci: CONTACT_A, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('MemberPrivilege items', async () => { const messages: Array = [ createMessage({ from: CONTACT_A, details: [ { type: 'member-privilege', aci: OUR_ACI, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], }), createMessage({ details: [ { type: 'member-privilege', aci: OUR_ACI, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'member-privilege', aci: CONTACT_A, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'member-privilege', aci: CONTACT_A, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], }), createMessage({ details: [ { type: 'member-privilege', aci: CONTACT_A, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'member-privilege', aci: OUR_ACI, newPrivilege: RoleEnum.DEFAULT, }, ], }), createMessage({ details: [ { type: 'member-privilege', aci: OUR_ACI, newPrivilege: RoleEnum.DEFAULT, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'member-privilege', aci: CONTACT_A, newPrivilege: RoleEnum.DEFAULT, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'member-privilege', aci: CONTACT_A, newPrivilege: RoleEnum.DEFAULT, }, ], }), createMessage({ details: [ { type: 'member-privilege', aci: CONTACT_A, newPrivilege: RoleEnum.DEFAULT, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('PendingAddOne items', async () => { const messages: Array = [ createMessage({ from: CONTACT_A, details: [ { type: 'pending-add-one', serviceId: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'pending-add-one', serviceId: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-add-one', serviceId: INVITEE_A, }, ], }), // These don't roundtrip because we only have details if we're involved. See the // next test. // createMessage({ // from: CONTACT_B, // details: [ // { // type: 'pending-add-one', // serviceId: INVITEE_A, // }, // ], // }), // createMessage({ // details: [ // { // type: 'pending-add-one', // serviceId: INVITEE_A, // }, // ], // }), ]; await symmetricRoundtripHarness(messages); }); it('PendingAddOne items, asymmetric', async () => { const firstBefore = createMessage({ from: CONTACT_B, details: [ { type: 'pending-add-one', serviceId: INVITEE_A, }, ], }); const firstAfter = createMessage( { from: CONTACT_B, details: [ { type: 'pending-add-many', count: 1, }, ], }, { disableIncrement: true } ); const secondBefore = createMessage({ details: [ { type: 'pending-add-one', serviceId: INVITEE_A, }, ], }); const secondAfter = createMessage( { details: [ { type: 'pending-add-many', count: 1, }, ], }, { disableIncrement: true } ); const before = [firstBefore, secondBefore]; const after = [firstAfter, secondAfter]; await asymmetricRoundtripHarness(before, after); }); it('PendingAddMany items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'pending-add-many', count: 5, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-add-many', count: 1, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'pending-add-many', count: 5, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'pending-add-many', count: 1, }, ], }), createMessage({ details: [ { type: 'pending-add-many', count: 5, }, ], }), createMessage({ details: [ { type: 'pending-add-many', count: 1, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('PendingRemoveOne items', async () => { const messages: Array = [ createMessage({ from: INVITEE_A, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, inviter: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: OUR_ACI, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: OUR_ACI, }, ], }), createMessage({ from: INVITEE_A, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, }, ], }), createMessage({ from: INVITEE_A, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, inviter: CONTACT_B, }, ], }), createMessage({ from: CONTACT_B, details: [ { type: 'pending-remove-one', serviceId: OUR_ACI, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: CONTACT_B, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'pending-remove-one', serviceId: CONTACT_B, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: CONTACT_A, }, ], }), createMessage({ from: CONTACT_C, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: CONTACT_B, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: CONTACT_B, }, ], }), createMessage({ details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, // Not roundtripped unless you were invited, or invitee said no to invite // inviter: CONTACT_B, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, }, ], }), createMessage({ from: CONTACT_B, details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, }, ], }), createMessage({ details: [ { type: 'pending-remove-one', serviceId: INVITEE_A, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('PendingRemoveMany items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-many', count: 5, // Inviter is not roundtripped for a multi-remove // inviter: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-many', count: 1, // Inviter is not roundtripped for a multi-remove // inviter: OUR_ACI, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'pending-remove-many', count: 5, // Inviter is not roundtripped for a multi-remove // inviter: OUR_ACI, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'pending-remove-many', count: 1, // Inviter is not roundtripped for a multi-remove // inviter: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'pending-remove-many', count: 5, // Inviter is not roundtripped for a multi-remove // inviter: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'pending-remove-many', count: 1, // Inviter is not roundtripped for a multi-remove // inviter: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-many', count: 5, // Inviter is not roundtripped for a multi-remove // inviter: CONTACT_A, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-many', count: 1, // Inviter is not roundtripped for a multi-remove // inviter: CONTACT_A, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'pending-remove-many', count: 5, // Inviter is not roundtripped for a multi-remove // inviter: CONTACT_A, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'pending-remove-many', count: 1, // Inviter is not roundtripped for a multi-remove // inviter: CONTACT_A, }, ], }), createMessage({ details: [ { type: 'pending-remove-many', count: 5, // Inviter is not roundtripped for a multi-remove // inviter: CONTACT_A, }, ], }), createMessage({ details: [ { type: 'pending-remove-many', count: 1, // Inviter is not roundtripped for a multi-remove // inviter: CONTACT_A, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-many', count: 5, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'pending-remove-many', count: 1, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'pending-remove-many', count: 5, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'pending-remove-many', count: 1, }, ], }), createMessage({ details: [ { type: 'pending-remove-many', count: 5, }, ], }), createMessage({ details: [ { type: 'pending-remove-many', count: 1, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('AdminApprovalAdd items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'admin-approval-add-one', aci: OUR_ACI, }, ], }), createMessage({ from: CONTACT_A, details: [ { type: 'admin-approval-add-one', aci: CONTACT_A, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('AdminApprovalRemove items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'admin-approval-remove-one', aci: OUR_ACI, }, ], }), createMessage({ details: [ { type: 'admin-approval-remove-one', aci: OUR_ACI, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'admin-approval-remove-one', aci: CONTACT_A, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('AdminApprovalBounce items', async () => { const messages: Array = [ // Should show button: createMessage({ // From Joiner from: CONTACT_A, details: [ { type: 'admin-approval-bounce', aci: CONTACT_A, times: 1, isApprovalPending: false, }, ], }), // These don't roundtrip, because we assume these always come from the requestor // createMessage({ // // From nobody // details: [ // { // type: 'admin-approval-bounce', // aci: CONTACT_A, // times: 1, // isApprovalPending: false, // }, // ], // }), // createMessage({ // details: [ // { // type: 'admin-approval-bounce', // aci: CONTACT_A, // times: 1, // isApprovalPending: false, // }, // // No group membership info // ], // }), // Would show button, but we're not admin: createMessage({ from: CONTACT_A, details: [ { type: 'admin-approval-bounce', aci: CONTACT_A, times: 1, isApprovalPending: false, }, ], }), // Would show button, but user is a group member: createMessage({ from: CONTACT_A, details: [ { type: 'admin-approval-bounce', aci: CONTACT_A, times: 1, isApprovalPending: false, }, ], }), // Would show button, but user is already banned: createMessage({ from: CONTACT_A, details: [ { type: 'admin-approval-bounce', aci: CONTACT_A, times: 1, isApprovalPending: false, }, ], }), // Open request createMessage({ from: CONTACT_A, details: [ { type: 'admin-approval-bounce', aci: CONTACT_A, times: 4, isApprovalPending: true, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('GroupLinkAdd items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'group-link-add', privilege: AccessControlEnum.ANY, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'group-link-add', privilege: AccessControlEnum.ANY, }, ], }), createMessage({ details: [ { type: 'group-link-add', privilege: AccessControlEnum.ANY, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'group-link-add', privilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'group-link-add', privilege: AccessControlEnum.ADMINISTRATOR, }, ], }), createMessage({ details: [ { type: 'group-link-add', privilege: AccessControlEnum.ADMINISTRATOR, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('GroupLinkReset items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'group-link-reset', }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'group-link-reset', }, ], }), createMessage({ details: [ { type: 'group-link-reset', }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('GroupLinkRemove items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'group-link-remove', }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'group-link-remove', }, ], }), createMessage({ details: [ { type: 'group-link-remove', }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('DescriptionRemove items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { removed: true, type: 'description', }, ], }), createMessage({ from: ADMIN_A, details: [ { removed: true, type: 'description', }, ], }), createMessage({ details: [ { removed: true, type: 'description', }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('DescriptionChange items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'description', description: 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'description', description: 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', }, ], }), createMessage({ details: [ { type: 'description', description: 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('DescriptionChange items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'announcements-only', announcementsOnly: true, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'announcements-only', announcementsOnly: true, }, ], }), createMessage({ details: [ { type: 'announcements-only', announcementsOnly: true, }, ], }), createMessage({ from: OUR_ACI, details: [ { type: 'announcements-only', announcementsOnly: false, }, ], }), createMessage({ from: ADMIN_A, details: [ { type: 'announcements-only', announcementsOnly: false, }, ], }), createMessage({ details: [ { type: 'announcements-only', announcementsOnly: false, }, ], }), ]; await symmetricRoundtripHarness(messages); }); it('Summary items', async () => { const messages: Array = [ createMessage({ from: OUR_ACI, details: [ { type: 'summary', }, ], }), ]; await symmetricRoundtripHarness(messages); }); }); describe('roundtrips given a timer change notification', () => { it('in a group', async () => { const groupConversation = window.ConversationController.get(GROUP_ID); strictAssert( groupConversation, 'The group conversation must be created!' ); counter += 1; const zeroTimer = { id: generateGuid(), conversationId: groupConversation.id, expirationTimerUpdate: { expireTimer: DurationInSeconds.fromSeconds(5), sourceServiceId: CONTACT_A, }, flags: EXPIRATION_TIMER_FLAG, type: 'timer-notification' as const, received_at: counter, sent_at: counter, timestamp: counter, }; counter += 1; const fiveSecondTimer = { id: generateGuid(), conversationId: groupConversation.id, expirationTimerUpdate: { expireTimer: DurationInSeconds.fromSeconds(5), sourceServiceId: CONTACT_A, }, flags: EXPIRATION_TIMER_FLAG, type: 'timer-notification' as const, received_at: counter, sent_at: counter, timestamp: counter, }; const messages: Array = [ zeroTimer, fiveSecondTimer, ]; await symmetricRoundtripHarness(messages); }); it('in a 1:1 conversation', async () => { const contactA = window.ConversationController.get(CONTACT_A); strictAssert(contactA, 'contactA conversation must be created!'); counter += 1; const zeroTimer = { id: generateGuid(), conversationId: contactA.id, expirationTimerUpdate: { expireTimer: DurationInSeconds.fromSeconds(0), sourceServiceId: CONTACT_A, }, sourceServiceId: CONTACT_A, flags: EXPIRATION_TIMER_FLAG, type: 'timer-notification' as const, received_at: counter, sent_at: counter, timestamp: counter, }; counter += 1; const fiveSecondTimer = { id: generateGuid(), conversationId: contactA.id, expirationTimerUpdate: { expireTimer: DurationInSeconds.fromSeconds(5), sourceServiceId: OUR_ACI, }, sourceServiceId: OUR_ACI, flags: EXPIRATION_TIMER_FLAG, type: 'timer-notification' as const, received_at: counter, sent_at: counter, timestamp: counter, }; const messages: Array = [ zeroTimer, fiveSecondTimer, ]; await symmetricRoundtripHarness(messages); }); }); describe('roundtrips given migration notifications', () => { it('symmetrically', async () => { const groupConversation = window.ConversationController.get(GROUP_ID); strictAssert( groupConversation, 'The group conversation must be created!' ); counter += 1; const droppedOnly = { id: generateGuid(), conversationId: groupConversation.id, groupMigration: { areWeInvited: false, droppedMemberCount: 2, invitedMemberCount: 0, }, type: 'group-v1-migration' as const, received_at: counter, sent_at: counter, timestamp: counter, }; counter += 1; const invitedOnly = { id: generateGuid(), conversationId: groupConversation.id, groupMigration: { areWeInvited: false, droppedMemberCount: 0, invitedMemberCount: 1, }, type: 'group-v1-migration' as const, received_at: counter, sent_at: counter, timestamp: counter, }; counter += 1; const bothAndInvited = { id: generateGuid(), conversationId: groupConversation.id, groupMigration: { areWeInvited: true, droppedMemberCount: 2, invitedMemberCount: 1, }, type: 'group-v1-migration' as const, received_at: counter, sent_at: counter, timestamp: counter, }; const messages: Array = [ droppedOnly, invitedOnly, bothAndInvited, ]; await symmetricRoundtripHarness(messages); }); it('asymmetrically', async () => { const groupConversation = window.ConversationController.get(GROUP_ID); strictAssert( groupConversation, 'The group conversation must be created!' ); counter += 1; const legacyBefore = { id: generateGuid(), conversationId: groupConversation.id, droppedGV2MemberIds: [CONTACT_C], invitedGV2Members: [ { uuid: CONTACT_A, timestamp: counter, role: RoleEnum.DEFAULT }, { uuid: CONTACT_B, timestamp: counter, role: RoleEnum.DEFAULT }, ], type: 'group-v1-migration' as const, received_at: counter, sent_at: counter, timestamp: counter, }; const legacyAfter = { id: generateGuid(), conversationId: groupConversation.id, groupMigration: { areWeInvited: false, droppedMemberCount: 1, invitedMemberCount: 2, }, type: 'group-v1-migration' as const, received_at: counter, sent_at: counter, timestamp: counter, }; counter += 1; const allDataBefore = { id: generateGuid(), conversationId: groupConversation.id, groupMigration: { areWeInvited: true, droppedMemberIds: [CONTACT_C], invitedMembers: [ { uuid: CONTACT_A, timestamp: counter, role: RoleEnum.DEFAULT }, { uuid: CONTACT_B, timestamp: counter, role: RoleEnum.DEFAULT }, ], }, type: 'group-v1-migration' as const, received_at: counter, sent_at: counter, timestamp: counter, }; const allDataAfter = { id: generateGuid(), conversationId: groupConversation.id, groupMigration: { areWeInvited: true, droppedMemberCount: 1, invitedMemberCount: 2, }, type: 'group-v1-migration' as const, received_at: counter, sent_at: counter, timestamp: counter, }; const before = [legacyBefore, allDataBefore]; const after = [legacyAfter, allDataAfter]; await asymmetricRoundtripHarness(before, after); }); }); });