Backup support for sticker messages
This commit is contained in:
parent
307e477035
commit
03406b15fa
5 changed files with 381 additions and 39 deletions
|
@ -37,6 +37,7 @@ import {
|
||||||
} from './mediaId';
|
} from './mediaId';
|
||||||
import { redactGenericText } from '../../../util/privacy';
|
import { redactGenericText } from '../../../util/privacy';
|
||||||
import { missingCaseError } from '../../../util/missingCaseError';
|
import { missingCaseError } from '../../../util/missingCaseError';
|
||||||
|
import { toLogFormat } from '../../../types/errors';
|
||||||
|
|
||||||
export function convertFilePointerToAttachment(
|
export function convertFilePointerToAttachment(
|
||||||
filePointer: Backups.FilePointer
|
filePointer: Backups.FilePointer
|
||||||
|
@ -225,6 +226,16 @@ export async function getFilePointerForAttachment({
|
||||||
attachment.digest ?? ''
|
attachment.digest ?? ''
|
||||||
)})`;
|
)})`;
|
||||||
|
|
||||||
|
if (attachment.size == null) {
|
||||||
|
log.warn(`${logId}: attachment had nullish size, dropping`);
|
||||||
|
return {
|
||||||
|
filePointer: new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
invalidAttachmentLocator: getInvalidAttachmentLocator(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAttachmentLocallySaved(attachment)) {
|
if (!isAttachmentLocallySaved(attachment)) {
|
||||||
// 1. If the attachment is undownloaded, we cannot trust its digest / mediaName. Thus,
|
// 1. If the attachment is undownloaded, we cannot trust its digest / mediaName. Thus,
|
||||||
// we only include a BackupLocator if this attachment already had one (e.g. we
|
// we only include a BackupLocator if this attachment already had one (e.g. we
|
||||||
|
@ -290,7 +301,7 @@ export async function getFilePointerForAttachment({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some attachments (e.g. those quoted ones copied from the original message) may not
|
// Some attachments (e.g. those quoted ones copied from the original message) may not
|
||||||
// have any encryption info, including a digest!
|
// have any encryption info, including a digest.
|
||||||
if (attachment.digest) {
|
if (attachment.digest) {
|
||||||
// From here on, this attachment is headed to (or already on) the backup tier!
|
// From here on, this attachment is headed to (or already on) the backup tier!
|
||||||
const mediaNameForCurrentVersionOfAttachment =
|
const mediaNameForCurrentVersionOfAttachment =
|
||||||
|
@ -326,9 +337,25 @@ export async function getFilePointerForAttachment({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`${logId}: Generating new encryption info for attachment`);
|
let attachmentWithNewEncryptionInfo: AttachmentReadyForBackup | undefined;
|
||||||
const attachmentWithNewEncryptionInfo =
|
try {
|
||||||
await generateNewEncryptionInfoForAttachment(attachment);
|
log.info(`${logId}: Generating new encryption info for attachment`);
|
||||||
|
attachmentWithNewEncryptionInfo =
|
||||||
|
await generateNewEncryptionInfoForAttachment(attachment);
|
||||||
|
} catch (e) {
|
||||||
|
log.error(
|
||||||
|
`${logId}: Error when generating new encryption info for attachment`,
|
||||||
|
toLogFormat(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePointer: new Backups.FilePointer({
|
||||||
|
...filePointerRootProps,
|
||||||
|
invalidAttachmentLocator: getInvalidAttachmentLocator(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePointer: new Backups.FilePointer({
|
filePointer: new Backups.FilePointer({
|
||||||
...filePointerRootProps,
|
...filePointerRootProps,
|
||||||
|
|
|
@ -59,7 +59,11 @@ import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
import { getMentionsRegex } from '../../types/Message';
|
import { getMentionsRegex } from '../../types/Message';
|
||||||
import { SignalService as Proto } from '../../protobuf';
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
|
import {
|
||||||
|
isVoiceMessage,
|
||||||
|
canBeDownloaded,
|
||||||
|
defaultBlurHash,
|
||||||
|
} from '../../types/Attachment';
|
||||||
import { type DefaultConversationColorType } from '../../types/Colors';
|
import { type DefaultConversationColorType } from '../../types/Colors';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
|
||||||
|
@ -301,16 +305,14 @@ export const getAttachmentsForMessage = ({
|
||||||
if (sticker && sticker.data) {
|
if (sticker && sticker.data) {
|
||||||
const { data } = sticker;
|
const { data } = sticker;
|
||||||
|
|
||||||
// We don't show anything if we don't have the sticker or the blurhash...
|
|
||||||
if (!data.blurHash && (data.pending || !data.path)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
// We want to show the blurhash for stickers, not the spinner
|
// We want to show the blurhash for stickers, not the spinner
|
||||||
pending: false,
|
pending: false,
|
||||||
|
// Stickers are not guaranteed to have a blurhash (e.g. if imported but
|
||||||
|
// undownloaded from backup), so we want to make sure we have something to show
|
||||||
|
blurHash: data.blurHash ?? defaultBlurHash(),
|
||||||
url: data.path
|
url: data.path
|
||||||
? window.Signal.Migrations.getAbsoluteAttachmentPath(data.path)
|
? window.Signal.Migrations.getAbsoluteAttachmentPath(data.path)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
import { v4 as generateGuid } from 'uuid';
|
import { v4 as generateGuid } from 'uuid';
|
||||||
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
import { omit } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
import type { ConversationModel } from '../../models/conversations';
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
import * as Bytes from '../../Bytes';
|
import * as Bytes from '../../Bytes';
|
||||||
|
@ -13,7 +16,13 @@ import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { SeenStatus } from '../../MessageSeenStatus';
|
import { SeenStatus } from '../../MessageSeenStatus';
|
||||||
import { loadCallsHistory } from '../../services/callHistoryLoader';
|
import { loadCallsHistory } from '../../services/callHistoryLoader';
|
||||||
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
|
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
|
||||||
import { AUDIO_MP3, IMAGE_JPEG, IMAGE_PNG, VIDEO_MP4 } from '../../types/MIME';
|
import {
|
||||||
|
AUDIO_MP3,
|
||||||
|
IMAGE_JPEG,
|
||||||
|
IMAGE_PNG,
|
||||||
|
IMAGE_WEBP,
|
||||||
|
VIDEO_MP4,
|
||||||
|
} from '../../types/MIME';
|
||||||
import type {
|
import type {
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
|
@ -21,10 +30,12 @@ import type {
|
||||||
import { isVoiceMessage, type AttachmentType } from '../../types/Attachment';
|
import { isVoiceMessage, type AttachmentType } from '../../types/Attachment';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { SignalService } from '../../protobuf';
|
import { SignalService } from '../../protobuf';
|
||||||
|
import { getRandomBytes } from '../../Crypto';
|
||||||
|
|
||||||
const CONTACT_A = generateAci();
|
const CONTACT_A = generateAci();
|
||||||
|
|
||||||
describe('backup/attachments', () => {
|
describe('backup/attachments', () => {
|
||||||
|
let sandbox: sinon.SinonSandbox;
|
||||||
let contactA: ConversationModel;
|
let contactA: ConversationModel;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
@ -41,6 +52,25 @@ describe('backup/attachments', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await loadCallsHistory();
|
await loadCallsHistory();
|
||||||
|
|
||||||
|
sandbox = sinon.createSandbox();
|
||||||
|
const getAbsoluteAttachmentPath = sandbox.stub(
|
||||||
|
window.Signal.Migrations,
|
||||||
|
'getAbsoluteAttachmentPath'
|
||||||
|
);
|
||||||
|
getAbsoluteAttachmentPath.callsFake(path => {
|
||||||
|
if (path === 'path/to/sticker') {
|
||||||
|
return join(__dirname, '../../../fixtures/kitten-3-64-64.jpg');
|
||||||
|
}
|
||||||
|
if (path === 'path/to/thumbnail') {
|
||||||
|
return join(__dirname, '../../../fixtures/kitten-3-64-64.jpg');
|
||||||
|
}
|
||||||
|
return getAbsoluteAttachmentPath.wrappedMethod(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sandbox.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getBase64(str: string): string {
|
function getBase64(str: string): string {
|
||||||
|
@ -66,7 +96,7 @@ describe('backup/attachments', () => {
|
||||||
width: 150,
|
width: 150,
|
||||||
height: 150,
|
height: 150,
|
||||||
contentType: IMAGE_PNG,
|
contentType: IMAGE_PNG,
|
||||||
path: '/path/to/thumbnail.png',
|
path: 'path/to/thumbnail',
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
@ -112,7 +142,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Messages
|
{ backupLevel: BackupLevel.Messages }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('BackupLevel.Media, roundtrips normal attachments', async () => {
|
it('BackupLevel.Media, roundtrips normal attachments', async () => {
|
||||||
|
@ -142,7 +172,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Media
|
{ backupLevel: BackupLevel.Media }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('roundtrips voice message attachments', async () => {
|
it('roundtrips voice message attachments', async () => {
|
||||||
|
@ -174,7 +204,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Media
|
{ backupLevel: BackupLevel.Media }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -201,7 +231,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Messages
|
{ backupLevel: BackupLevel.Messages }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('BackupLevel.Media, roundtrips preview attachments', async () => {
|
it('BackupLevel.Media, roundtrips preview attachments', async () => {
|
||||||
|
@ -245,7 +275,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Media
|
{ backupLevel: BackupLevel.Media }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -273,7 +303,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Messages
|
{ backupLevel: BackupLevel.Messages }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('BackupLevel.Media, roundtrips contact attachments', async () => {
|
it('BackupLevel.Media, roundtrips contact attachments', async () => {
|
||||||
|
@ -308,7 +338,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Media
|
{ backupLevel: BackupLevel.Media }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -348,7 +378,7 @@ describe('backup/attachments', () => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Messages
|
{ backupLevel: BackupLevel.Messages }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('BackupLevel.Media, roundtrips quote attachments', async () => {
|
it('BackupLevel.Media, roundtrips quote attachments', async () => {
|
||||||
|
@ -393,7 +423,7 @@ describe('backup/attachments', () => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
BackupLevel.Media
|
{ backupLevel: BackupLevel.Media }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -459,8 +489,241 @@ describe('backup/attachments', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
BackupLevel.Media
|
{ backupLevel: BackupLevel.Media }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles quotes which have been copied over from the original (and lack all encryption info)', async () => {
|
||||||
|
const originalMessage = composeMessage(1);
|
||||||
|
const quotedMessage: QuotedMessageType = {
|
||||||
|
authorAci: originalMessage.sourceServiceId as AciString,
|
||||||
|
isViewOnce: false,
|
||||||
|
id: originalMessage.timestamp,
|
||||||
|
referencedMessageNotFound: false,
|
||||||
|
messageId: '',
|
||||||
|
isGiftBadge: false,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
thumbnail: {
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
size: 100,
|
||||||
|
path: 'path/to/thumbnail',
|
||||||
|
},
|
||||||
|
contentType: VIDEO_MP4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const quoteMessage = composeMessage(originalMessage.timestamp + 1, {
|
||||||
|
quote: quotedMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await asymmetricRoundtripHarness(
|
||||||
|
[originalMessage, quoteMessage],
|
||||||
|
[
|
||||||
|
originalMessage,
|
||||||
|
{
|
||||||
|
...quoteMessage,
|
||||||
|
quote: {
|
||||||
|
...quotedMessage,
|
||||||
|
referencedMessageNotFound: false,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
// will do custom comparison for thumbnail below
|
||||||
|
contentType: VIDEO_MP4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
backupLevel: BackupLevel.Media,
|
||||||
|
comparator: (msgBefore, msgAfter) => {
|
||||||
|
if (msgBefore.timestamp === originalMessage.timestamp) {
|
||||||
|
return assert.deepStrictEqual(msgBefore, msgAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnail = msgAfter.quote?.attachments[0]?.thumbnail;
|
||||||
|
strictAssert(thumbnail, 'quote thumbnail exists');
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
omit(msgBefore, 'quote.attachments[0].thumbnail'),
|
||||||
|
omit(msgAfter, 'quote.attachments[0].thumbnail')
|
||||||
|
);
|
||||||
|
|
||||||
|
const { key, digest } = thumbnail;
|
||||||
|
strictAssert(digest, 'quote digest was created');
|
||||||
|
strictAssert(key, 'quote digest was created');
|
||||||
|
|
||||||
|
assert.deepStrictEqual(thumbnail, {
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
size: 100,
|
||||||
|
key,
|
||||||
|
digest,
|
||||||
|
backupLocator: {
|
||||||
|
mediaName: digest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sticker attachments', () => {
|
||||||
|
const packId = Bytes.toHex(getRandomBytes(16));
|
||||||
|
const packKey = Bytes.toBase64(getRandomBytes(32));
|
||||||
|
|
||||||
|
describe('when copied over from sticker pack (i.e. missing encryption info)', () => {
|
||||||
|
it('BackupLevel.Media, generates new encryption info', async () => {
|
||||||
|
await asymmetricRoundtripHarness(
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
sticker: {
|
||||||
|
emoji: '🐒',
|
||||||
|
packId,
|
||||||
|
packKey,
|
||||||
|
stickerId: 0,
|
||||||
|
data: {
|
||||||
|
contentType: IMAGE_WEBP,
|
||||||
|
path: 'path/to/sticker',
|
||||||
|
size: 5322,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
sticker: {
|
||||||
|
emoji: '🐒',
|
||||||
|
packId,
|
||||||
|
packKey,
|
||||||
|
stickerId: 0,
|
||||||
|
data: {
|
||||||
|
contentType: IMAGE_WEBP,
|
||||||
|
size: 5322,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
backupLevel: BackupLevel.Media,
|
||||||
|
comparator: (msgBefore, msgAfter) => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
omit(msgBefore, 'sticker.data'),
|
||||||
|
omit(msgAfter, 'sticker.data')
|
||||||
|
);
|
||||||
|
strictAssert(msgAfter.sticker?.data, 'sticker data exists');
|
||||||
|
|
||||||
|
const { key, digest } = msgAfter.sticker.data;
|
||||||
|
strictAssert(digest, 'sticker digest was created');
|
||||||
|
|
||||||
|
assert.equal(Bytes.fromBase64(digest ?? '').byteLength, 32);
|
||||||
|
assert.equal(Bytes.fromBase64(key ?? '').byteLength, 64);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(msgAfter.sticker.data, {
|
||||||
|
contentType: IMAGE_WEBP,
|
||||||
|
size: 5322,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
key,
|
||||||
|
digest,
|
||||||
|
backupLocator: {
|
||||||
|
mediaName: digest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('BackupLevel.Messages, generates invalid attachment locator', async () => {
|
||||||
|
// since we aren't re-uploading with new encryption info, we can't include this
|
||||||
|
// attachment in the backup proto
|
||||||
|
await asymmetricRoundtripHarness(
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
sticker: {
|
||||||
|
emoji: '🐒',
|
||||||
|
packId,
|
||||||
|
packKey,
|
||||||
|
stickerId: 0,
|
||||||
|
data: {
|
||||||
|
contentType: IMAGE_WEBP,
|
||||||
|
path: 'path/to/sticker',
|
||||||
|
size: 5322,
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
sticker: {
|
||||||
|
emoji: '🐒',
|
||||||
|
packId,
|
||||||
|
packKey,
|
||||||
|
stickerId: 0,
|
||||||
|
data: {
|
||||||
|
contentType: IMAGE_WEBP,
|
||||||
|
size: 0,
|
||||||
|
error: true,
|
||||||
|
height: 512,
|
||||||
|
width: 512,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
backupLevel: BackupLevel.Messages,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when this device sent sticker (i.e. encryption info exists on message)', () => {
|
||||||
|
it('roundtrips sticker', async () => {
|
||||||
|
const attachment = composeAttachment(1);
|
||||||
|
strictAssert(attachment.digest, 'digest exists');
|
||||||
|
await asymmetricRoundtripHarness(
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
sticker: {
|
||||||
|
emoji: '🐒',
|
||||||
|
packId,
|
||||||
|
packKey,
|
||||||
|
stickerId: 0,
|
||||||
|
data: attachment,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
sticker: {
|
||||||
|
emoji: '🐒',
|
||||||
|
packId,
|
||||||
|
packKey,
|
||||||
|
stickerId: 0,
|
||||||
|
data: {
|
||||||
|
...omit(attachment, [
|
||||||
|
'iv',
|
||||||
|
'path',
|
||||||
|
'thumbnail',
|
||||||
|
'uploadTimestamp',
|
||||||
|
]),
|
||||||
|
backupLocator: { mediaName: attachment.digest },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
backupLevel: BackupLevel.Media,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,6 +16,7 @@ import * as Bytes from '../../Bytes';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId';
|
import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId';
|
||||||
|
import { MASTER_KEY } from './helpers';
|
||||||
|
|
||||||
describe('convertFilePointerToAttachment', () => {
|
describe('convertFilePointerToAttachment', () => {
|
||||||
it('processes filepointer with attachmentLocator', () => {
|
it('processes filepointer with attachmentLocator', () => {
|
||||||
|
@ -270,6 +271,22 @@ const notInBackupCdn: GetBackupCdnInfoType = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('getFilePointerForAttachment', () => {
|
describe('getFilePointerForAttachment', () => {
|
||||||
|
let sandbox: sinon.SinonSandbox;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sandbox = sinon.createSandbox();
|
||||||
|
sandbox.stub(window.storage, 'get').callsFake(key => {
|
||||||
|
if (key === 'masterKey') {
|
||||||
|
return MASTER_KEY;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sandbox.restore();
|
||||||
|
});
|
||||||
|
|
||||||
describe('not downloaded locally', () => {
|
describe('not downloaded locally', () => {
|
||||||
const undownloadedAttachment = composeAttachment({ path: undefined });
|
const undownloadedAttachment = composeAttachment({ path: undefined });
|
||||||
it('returns invalidAttachmentLocator if missing critical decryption info', async () => {
|
it('returns invalidAttachmentLocator if missing critical decryption info', async () => {
|
||||||
|
@ -402,11 +419,7 @@ describe('getFilePointerForAttachment', () => {
|
||||||
describe('BackupLevel.Media', () => {
|
describe('BackupLevel.Media', () => {
|
||||||
describe('if missing critical decryption / encryption info', () => {
|
describe('if missing critical decryption / encryption info', () => {
|
||||||
const FILE_PATH = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
|
const FILE_PATH = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
|
||||||
|
|
||||||
let sandbox: sinon.SinonSandbox;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sandbox = sinon.createSandbox();
|
|
||||||
sandbox
|
sandbox
|
||||||
.stub(window.Signal.Migrations, 'getAbsoluteAttachmentPath')
|
.stub(window.Signal.Migrations, 'getAbsoluteAttachmentPath')
|
||||||
.callsFake(relPath => {
|
.callsFake(relPath => {
|
||||||
|
@ -417,10 +430,6 @@ describe('getFilePointerForAttachment', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
sandbox.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('if missing key, generates new key & digest and removes existing CDN info', async () => {
|
it('if missing key, generates new key & digest and removes existing CDN info', async () => {
|
||||||
const { filePointer: result } = await getFilePointerForAttachment({
|
const { filePointer: result } = await getFilePointerForAttachment({
|
||||||
attachment: {
|
attachment: {
|
||||||
|
@ -450,6 +459,18 @@ describe('getFilePointerForAttachment', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('if file does not exist at local path, returns invalid attachment locator', async () => {
|
||||||
|
await testAttachmentToFilePointer(
|
||||||
|
{
|
||||||
|
...downloadedAttachment,
|
||||||
|
path: 'no/file/here.png',
|
||||||
|
key: undefined,
|
||||||
|
},
|
||||||
|
filePointerWithInvalidLocator,
|
||||||
|
{ backupLevel: BackupLevel.Media }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('if not on backup tier, and missing iv, regenerates encryption info', async () => {
|
it('if not on backup tier, and missing iv, regenerates encryption info', async () => {
|
||||||
const { filePointer: result } = await getFilePointerForAttachment({
|
const { filePointer: result } = await getFilePointerForAttachment({
|
||||||
attachment: {
|
attachment: {
|
||||||
|
|
|
@ -10,7 +10,11 @@ import { mkdtemp, rm } from 'fs/promises';
|
||||||
import * as sinon from 'sinon';
|
import * as sinon from 'sinon';
|
||||||
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||||
|
|
||||||
import type { MessageAttributesType } from '../../model-types';
|
import type {
|
||||||
|
EditHistoryType,
|
||||||
|
MessageAttributesType,
|
||||||
|
MessageReactionType,
|
||||||
|
} from '../../model-types';
|
||||||
import type {
|
import type {
|
||||||
SendStateByConversationId,
|
SendStateByConversationId,
|
||||||
SendState,
|
SendState,
|
||||||
|
@ -31,18 +35,27 @@ export const PROFILE_KEY = getRandomBytes(32);
|
||||||
// This is preserved across data erasure
|
// This is preserved across data erasure
|
||||||
const CONVO_ID_TO_STABLE_ID = new Map<string, string>();
|
const CONVO_ID_TO_STABLE_ID = new Map<string, string>();
|
||||||
|
|
||||||
function mapConvoId(id?: string | null): string | undefined | null {
|
function mapConvoId(id?: string | null): string | undefined {
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
return id;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return CONVO_ID_TO_STABLE_ID.get(id) ?? id;
|
return CONVO_ID_TO_STABLE_ID.get(id) ?? id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MessageAttributesForComparisonType = Omit<
|
||||||
|
MessageAttributesType,
|
||||||
|
'id' | 'received_at' | 'editHistory' | 'reactions' | 'conversationId'
|
||||||
|
> & {
|
||||||
|
conversationId: string | undefined;
|
||||||
|
editHistory?: Array<Omit<EditHistoryType, 'received_at'>>;
|
||||||
|
reactions?: Array<Omit<MessageReactionType, 'fromId'>>;
|
||||||
|
};
|
||||||
|
|
||||||
// We need to eliminate fields that won't stay stable through import/export
|
// We need to eliminate fields that won't stay stable through import/export
|
||||||
function sortAndNormalize(
|
function sortAndNormalize(
|
||||||
messages: Array<MessageAttributesType>
|
messages: Array<MessageAttributesType>
|
||||||
): Array<unknown> {
|
): Array<MessageAttributesForComparisonType> {
|
||||||
return sortBy(messages, 'sent_at').map(message => {
|
return sortBy(messages, 'sent_at').map(message => {
|
||||||
const {
|
const {
|
||||||
changedId,
|
changedId,
|
||||||
|
@ -113,11 +126,19 @@ function sortAndNormalize(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HarnessOptionsType = {
|
||||||
|
backupLevel: BackupLevel;
|
||||||
|
comparator?: (
|
||||||
|
msgBefore: MessageAttributesForComparisonType,
|
||||||
|
msgAfter: MessageAttributesForComparisonType
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export async function symmetricRoundtripHarness(
|
export async function symmetricRoundtripHarness(
|
||||||
messages: Array<MessageAttributesType>,
|
messages: Array<MessageAttributesType>,
|
||||||
backupLevel: BackupLevel = BackupLevel.Messages
|
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return asymmetricRoundtripHarness(messages, messages, backupLevel);
|
return asymmetricRoundtripHarness(messages, messages, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateConvoIdToTitle() {
|
async function updateConvoIdToTitle() {
|
||||||
|
@ -133,7 +154,7 @@ async function updateConvoIdToTitle() {
|
||||||
export async function asymmetricRoundtripHarness(
|
export async function asymmetricRoundtripHarness(
|
||||||
before: Array<MessageAttributesType>,
|
before: Array<MessageAttributesType>,
|
||||||
after: Array<MessageAttributesType>,
|
after: Array<MessageAttributesType>,
|
||||||
backupLevel: BackupLevel = BackupLevel.Messages
|
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-'));
|
const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-'));
|
||||||
const fetchAndSaveBackupCdnObjectMetadata = sinon.stub(
|
const fetchAndSaveBackupCdnObjectMetadata = sinon.stub(
|
||||||
|
@ -145,7 +166,7 @@ export async function asymmetricRoundtripHarness(
|
||||||
|
|
||||||
await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI });
|
await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI });
|
||||||
|
|
||||||
await backupsService.exportToDisk(targetOutputFile, backupLevel);
|
await backupsService.exportToDisk(targetOutputFile, options.backupLevel);
|
||||||
|
|
||||||
await updateConvoIdToTitle();
|
await updateConvoIdToTitle();
|
||||||
|
|
||||||
|
@ -159,7 +180,15 @@ export async function asymmetricRoundtripHarness(
|
||||||
|
|
||||||
const expected = sortAndNormalize(after);
|
const expected = sortAndNormalize(after);
|
||||||
const actual = sortAndNormalize(messagesFromDatabase);
|
const actual = sortAndNormalize(messagesFromDatabase);
|
||||||
assert.deepEqual(actual, expected);
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
fetchAndSaveBackupCdnObjectMetadata.restore();
|
fetchAndSaveBackupCdnObjectMetadata.restore();
|
||||||
await rm(outDir, { recursive: true });
|
await rm(outDir, { recursive: true });
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue