Export/import simple update messages

This commit is contained in:
Fedor Indutny 2024-05-22 09:34:19 -07:00 committed by GitHub
parent 19083cadf7
commit 9df3c63ca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1604 additions and 386 deletions

View file

@ -1,20 +1,12 @@
// 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 { generateAci, generatePni } from '../../types/ServiceId';
import type { MessageAttributesType } from '../../model-types';
import type { GroupV2ChangeType } from '../../groups';
import { getRandomBytes } from '../../Crypto';
@ -22,6 +14,13 @@ import * as Bytes from '../../Bytes';
import { loadCallsHistory } from '../../services/callHistoryLoader';
import { strictAssert } from '../../util/assert';
import { DurationInSeconds } from '../../util/durations';
import {
OUR_ACI,
OUR_PNI,
setupBasics,
asymmetricRoundtripHarness,
symmetricRoundtripHarness,
} from './helpers';
// Note: this should be kept up to date with GroupV2Change.stories.tsx, to
// maintain the comprehensive set of GroupV2 notifications we need to handle
@ -30,8 +29,6 @@ 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();
@ -40,82 +37,6 @@ 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<MessageAttributesType>
): Array<Partial<MessageAttributesType>> {
return sortBy(messages, 'sent_at').map(message =>
pick(
message,
'droppedGV2MemberIds',
'expirationTimerUpdate',
'groupMigration',
'groupV2Change',
'invitedGV2Members',
'sent_at',
'timestamp',
'type'
)
);
}
async function symmetricRoundtripHarness(
messages: Array<MessageAttributesType>
) {
return asymmetricRoundtripHarness(messages, messages);
}
async function asymmetricRoundtripHarness(
before: Array<MessageAttributesType>,
after: Array<MessageAttributesType>
) {
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;
@ -182,11 +103,6 @@ describe('backup/groupv2/notifications', () => {
});
await loadCallsHistory();
window.Events = {
...window.Events,
getTypingIndicatorSetting: () => false,
getLinkPreviewSetting: () => false,
};
});
describe('roundtrips given groupv2 notifications with', () => {

View file

@ -0,0 +1,148 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import path from 'path';
import { tmpdir } from 'os';
import { pick, sortBy } from 'lodash';
import { createReadStream } from 'fs';
import { mkdtemp, rm } from 'fs/promises';
import type { MessageAttributesType } from '../../model-types';
import { backupsService } from '../../services/backups';
import { generateAci, generatePni } from '../../types/ServiceId';
import Data from '../../sql/Client';
import { getRandomBytes } from '../../Crypto';
import * as Bytes from '../../Bytes';
export const OUR_ACI = generateAci();
export const OUR_PNI = generatePni();
export const MASTER_KEY = Bytes.toBase64(getRandomBytes(32));
export const PROFILE_KEY = getRandomBytes(32);
// This is preserved across data erasure
const CONVO_ID_TO_STABLE_ID = new Map<string, string>();
function mapConvoId(id?: string | null): string | undefined | null {
if (id == null) {
return id;
}
return CONVO_ID_TO_STABLE_ID.get(id) ?? id;
}
// We need to eliminate fields that won't stay stable through import/export
function sortAndNormalize(
messages: Array<MessageAttributesType>
): Array<unknown> {
return sortBy(messages, 'sent_at').map(message => {
const shallow = pick(
message,
'contact',
'conversationMerge',
'droppedGV2MemberIds',
'expirationTimerUpdate',
'flags',
'groupMigration',
'groupV2Change',
'invitedGV2Members',
'isErased',
'payment',
'profileChange',
'sent_at',
'sticker',
'timestamp',
'type',
'verified'
);
return {
...shallow,
reactions: message.reactions?.map(({ fromId, ...rest }) => {
return {
from: mapConvoId(fromId),
...rest,
};
}),
changedId: mapConvoId(message.changedId),
key_changed: mapConvoId(message.key_changed),
verifiedChanged: mapConvoId(message.verifiedChanged),
};
});
}
export async function symmetricRoundtripHarness(
messages: Array<MessageAttributesType>
): Promise<void> {
return asymmetricRoundtripHarness(messages, messages);
}
async function updateConvoIdToTitle() {
const all = await Data.getAllConversations();
for (const convo of all) {
CONVO_ID_TO_STABLE_ID.set(
convo.id,
convo.serviceId ?? convo.e164 ?? convo.id
);
}
}
export async function asymmetricRoundtripHarness(
before: Array<MessageAttributesType>,
after: Array<MessageAttributesType>
): Promise<void> {
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 updateConvoIdToTitle();
await clearData();
await backupsService.importBackup(() => createReadStream(targetOutputFile));
const messagesFromDatabase = await Data._getAllMessages();
await updateConvoIdToTitle();
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();
}
export async function setupBasics(): Promise<void> {
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', PROFILE_KEY);
await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', {
pni: OUR_PNI,
systemGivenName: 'ME',
profileKey: Bytes.toBase64(PROFILE_KEY),
});
window.Events = {
...window.Events,
getTypingIndicatorSetting: () => false,
getLinkPreviewSetting: () => false,
};
}

View file

@ -0,0 +1,410 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateGuid } from 'uuid';
import Long from 'long';
import type { ConversationModel } from '../../models/conversations';
import { getRandomBytes } from '../../Crypto';
import * as Bytes from '../../Bytes';
import { SignalService as Proto, Backups } from '../../protobuf';
import Data from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import { PaymentEventKind } from '../../types/Payment';
import { ContactFormType } from '../../types/EmbeddedContact';
import { DurationInSeconds } from '../../util/durations';
import { loadCallsHistory } from '../../services/callHistoryLoader';
import { setupBasics, symmetricRoundtripHarness } from './helpers';
const CONTACT_A = generateAci();
const GROUP_ID = Bytes.toBase64(getRandomBytes(32));
describe('backup/non-bubble messages', () => {
let contactA: ConversationModel;
let group: ConversationModel;
beforeEach(async () => {
await Data._removeAllMessages();
await Data._removeAllConversations();
window.storage.reset();
await setupBasics();
contactA = await window.ConversationController.getOrCreateAndWait(
CONTACT_A,
'private',
{ systemGivenName: 'CONTACT_A' }
);
group = await window.ConversationController.getOrCreateAndWait(
GROUP_ID,
'group',
{
groupVersion: 2,
masterKey: Bytes.toBase64(getRandomBytes(32)),
name: 'Rock Enthusiasts',
}
);
await loadCallsHistory();
});
it('roundtrips END_SESSION simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
flags: Proto.DataMessage.Flags.END_SESSION,
},
]);
});
it('roundtrips CHAT_SESSION_REFRESH simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'chat-session-refreshed',
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips IDENTITY_CHANGE update in direct convos', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'keychange',
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips IDENTITY_CHANGE update in groups', async () => {
await symmetricRoundtripHarness([
{
conversationId: group.id,
id: generateGuid(),
type: 'keychange',
key_changed: contactA.id,
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips IDENTITY_DEFAULT simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'verified-change',
verifiedChanged: contactA.id,
verified: false,
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips IDENTITY_VERIFIED simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'verified-change',
verifiedChanged: contactA.id,
verified: true,
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips CHANGE_NUMBER simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'change-number-notification',
sourceServiceId: CONTACT_A,
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips JOINED_SIGNAL simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'joined-signal-notification',
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips BAD_DECRYPT simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'delivery-issue',
sourceServiceId: CONTACT_A,
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips PAYMENTS_ACTIVATED simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
sourceServiceId: CONTACT_A,
payment: {
kind: PaymentEventKind.Activation,
},
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
it('roundtrips PAYMENT_ACTIVATION_REQUEST simple update', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
sourceServiceId: CONTACT_A,
payment: {
kind: PaymentEventKind.ActivationRequest,
},
received_at: 1,
sent_at: 1,
timestamp: 1,
},
]);
});
// TODO: DESKTOP-7122
it.skip('roundtrips bare payments notification', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
payment: {
kind: PaymentEventKind.Notification,
note: 'note with text',
},
},
]);
});
// TODO: DESKTOP-7122
it.skip('roundtrips full payments notification', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
payment: {
kind: PaymentEventKind.Notification,
note: 'note with text',
amountMob: '1.01',
feeMob: '0.01',
transactionDetailsBase64: Bytes.toBase64(
Backups.PaymentNotification.TransactionDetails.encode({
transaction: {
timestamp: Long.fromNumber(Date.now()),
},
}).finish()
),
},
},
]);
});
it('roundtrips embedded contact', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
contact: [
{
name: {
givenName: 'Alice',
familyName: 'Smith',
},
number: [
{
type: ContactFormType.MOBILE,
value: '+121255501234',
},
],
organization: 'Signal',
},
],
reactions: [
{
emoji: '👍',
fromId: contactA.id,
targetTimestamp: 1,
timestamp: 1,
receivedAtDate: 1,
},
],
},
]);
});
it('roundtrips sticker', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
// TODO (DESKTOP-6845): properly handle data FilePointer
sticker: {
emoji: '👍',
packId: Bytes.toHex(getRandomBytes(16)),
stickerId: 1,
packKey: Bytes.toBase64(getRandomBytes(32)),
},
reactions: [
{
emoji: '👍',
fromId: contactA.id,
targetTimestamp: 1,
timestamp: 1,
receivedAtDate: 1,
},
],
},
]);
});
it('roundtrips remote deleted message', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
isErased: true,
},
]);
});
it('roundtrips timer notification in direct convos', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'timer-notification',
received_at: 1,
sent_at: 1,
timestamp: 1,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
expirationTimerUpdate: {
expireTimer: DurationInSeconds.fromMillis(5000),
sourceServiceId: CONTACT_A,
},
},
]);
});
it('roundtrips profile change notification', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'profile-change',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
changedId: contactA.id,
profileChange: {
type: 'name',
oldName: 'Old Name',
newName: 'New Name',
},
},
]);
});
it('roundtrips thread merge', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'conversation-merge',
received_at: 1,
sent_at: 1,
timestamp: 1,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
conversationMerge: {
renderInfo: {
type: 'private',
e164: '+12125551234',
},
},
},
]);
});
});