signal-desktop/ts/test-electron/backup/helpers.ts

229 lines
6.8 KiB
TypeScript
Raw Normal View History

2024-05-22 16:34:19 +00:00
// 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 { sortBy } from 'lodash';
2024-05-22 16:34:19 +00:00
import { createReadStream } from 'fs';
import { mkdtemp, rm } from 'fs/promises';
2024-05-29 23:46:43 +00:00
import * as sinon from 'sinon';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
2024-05-22 16:34:19 +00:00
2024-06-11 21:22:54 +00:00
import type {
EditHistoryType,
MessageAttributesType,
MessageReactionType,
} from '../../model-types';
import type {
SendStateByConversationId,
SendState,
} from '../../messages/MessageSendState';
2024-05-22 16:34:19 +00:00
import { backupsService } from '../../services/backups';
import { isUnsupportedMessage } from '../../state/selectors/message';
2024-05-22 16:34:19 +00:00
import { generateAci, generatePni } from '../../types/ServiceId';
2024-07-22 18:16:33 +00:00
import { DataReader, DataWriter } from '../../sql/Client';
2024-05-22 16:34:19 +00:00
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>();
2024-06-11 21:22:54 +00:00
function mapConvoId(id?: string | null): string | undefined {
2024-05-22 16:34:19 +00:00
if (id == null) {
2024-06-11 21:22:54 +00:00
return undefined;
2024-05-22 16:34:19 +00:00
}
return CONVO_ID_TO_STABLE_ID.get(id) ?? id;
}
2024-06-11 21:22:54 +00:00
type MessageAttributesForComparisonType = Omit<
MessageAttributesType,
'id' | 'received_at' | 'editHistory' | 'reactions' | 'conversationId'
> & {
conversationId: string | undefined;
editHistory?: Array<Omit<EditHistoryType, 'received_at'>>;
reactions?: Array<Omit<MessageReactionType, 'fromId'>>;
};
2024-05-22 16:34:19 +00:00
// We need to eliminate fields that won't stay stable through import/export
function sortAndNormalize(
messages: Array<MessageAttributesType>
2024-06-11 21:22:54 +00:00
): Array<MessageAttributesForComparisonType> {
2024-05-22 16:34:19 +00:00
return sortBy(messages, 'sent_at').map(message => {
const {
changedId,
conversationId,
editHistory,
key_changed: keyChanged,
reactions,
sendStateByConversationId,
verifiedChanged,
// This is not in the backup
// eslint-disable-next-line @typescript-eslint/no-unused-vars
id: _id,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
received_at: _receivedAt,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
sourceDevice: _sourceDevice,
2024-06-03 17:02:25 +00:00
// eslint-disable-next-line @typescript-eslint/no-unused-vars
editMessageReceivedAt: _editMessageReceivedAt,
...rest
} = message;
function mapSendState(
sendState?: SendStateByConversationId
): SendStateByConversationId | undefined {
if (sendState == null) {
return undefined;
}
const result: Record<string, SendState> = {};
for (const [id, state] of Object.entries(sendState)) {
result[mapConvoId(id) ?? id] = state;
}
return result;
}
2024-05-22 16:34:19 +00:00
// Get rid of unserializable `undefined` values.
return JSON.parse(
JSON.stringify({
...rest,
conversationId: mapConvoId(conversationId),
reactions: reactions?.map(({ fromId, ...restOfReaction }) => {
return {
from: mapConvoId(fromId),
...restOfReaction,
};
}),
changedId: mapConvoId(changedId),
key_changed: mapConvoId(keyChanged),
verifiedChanged: mapConvoId(verifiedChanged),
sendStateByConverationId: mapSendState(sendStateByConversationId),
editHistory: editHistory?.map(history => {
const {
sendStateByConversationId: historySendState,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
received_at: _receivedAtHistory,
...restOfHistory
} = history;
return {
...restOfHistory,
sendStateByConversationId: mapSendState(historySendState),
};
}),
// Not an original property, but useful
isUnsupported: isUnsupportedMessage(message),
})
);
2024-05-22 16:34:19 +00:00
});
}
2024-06-11 21:22:54 +00:00
type HarnessOptionsType = {
backupLevel: BackupLevel;
comparator?: (
msgBefore: MessageAttributesForComparisonType,
msgAfter: MessageAttributesForComparisonType
) => void;
};
2024-05-22 16:34:19 +00:00
export async function symmetricRoundtripHarness(
messages: Array<MessageAttributesType>,
2024-06-11 21:22:54 +00:00
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
2024-05-22 16:34:19 +00:00
): Promise<void> {
2024-06-11 21:22:54 +00:00
return asymmetricRoundtripHarness(messages, messages, options);
2024-05-22 16:34:19 +00:00
}
async function updateConvoIdToTitle() {
2024-07-22 18:16:33 +00:00
const all = await DataReader.getAllConversations();
2024-05-22 16:34:19 +00:00
for (const convo of all) {
CONVO_ID_TO_STABLE_ID.set(
convo.id,
convo.serviceId ?? convo.e164 ?? convo.masterKey ?? convo.id
2024-05-22 16:34:19 +00:00
);
}
}
export async function asymmetricRoundtripHarness(
before: Array<MessageAttributesType>,
after: Array<MessageAttributesType>,
2024-06-11 21:22:54 +00:00
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
2024-05-22 16:34:19 +00:00
): Promise<void> {
const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-'));
2024-05-29 23:46:43 +00:00
const fetchAndSaveBackupCdnObjectMetadata = sinon.stub(
backupsService,
'fetchAndSaveBackupCdnObjectMetadata'
);
2024-05-22 16:34:19 +00:00
try {
const targetOutputFile = path.join(outDir, 'backup.bin');
2024-07-22 18:16:33 +00:00
await DataWriter.saveMessages(before, { forceSave: true, ourAci: OUR_ACI });
2024-05-22 16:34:19 +00:00
2024-06-11 21:22:54 +00:00
await backupsService.exportToDisk(targetOutputFile, options.backupLevel);
2024-05-22 16:34:19 +00:00
await updateConvoIdToTitle();
await clearData();
await backupsService.importBackup(() => createReadStream(targetOutputFile));
2024-07-22 18:16:33 +00:00
const messagesFromDatabase = await DataReader._getAllMessages();
2024-05-22 16:34:19 +00:00
await updateConvoIdToTitle();
const expected = sortAndNormalize(after);
const actual = sortAndNormalize(messagesFromDatabase);
2024-06-11 21:22:54 +00:00
if (options.comparator) {
assert.strictEqual(actual.length, expected.length);
for (let i = 0; i < actual.length; i += 1) {
options.comparator(expected[i], actual[i]);
}
} else {
assert.deepEqual(actual, expected);
}
2024-05-22 16:34:19 +00:00
} finally {
2024-05-29 23:46:43 +00:00
fetchAndSaveBackupCdnObjectMetadata.restore();
2024-05-22 16:34:19 +00:00
await rm(outDir, { recursive: true });
}
}
async function clearData() {
2024-07-22 18:16:33 +00:00
await DataWriter._removeAllMessages();
await DataWriter._removeAllConversations();
await DataWriter.removeAllItems();
2024-05-22 16:34:19 +00:00
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,
};
}