You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			515 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			515 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
| /* global Signal: false */
 | |
| /* global Whisper: false */
 | |
| /* global assert: false */
 | |
| /* global textsecure: false */
 | |
| /* global _: false */
 | |
| 
 | |
| 'use strict';
 | |
| 
 | |
| describe('Backup', () => {
 | |
|   describe('_sanitizeFileName', () => {
 | |
|     it('leaves a basic string alone', () => {
 | |
|       const initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
 | |
|       const expected = initial;
 | |
|       assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
 | |
|     });
 | |
| 
 | |
|     it('replaces all unknown characters', () => {
 | |
|       const initial = '!@$%^&*=';
 | |
|       const expected = '________';
 | |
|       assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('_trimFileName', () => {
 | |
|     it('handles a file with no extension', () => {
 | |
|       const initial = '0123456789012345678901234567890123456789';
 | |
|       const expected = '012345678901234567890123456789';
 | |
|       assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
 | |
|     });
 | |
| 
 | |
|     it('handles a file with a long extension', () => {
 | |
|       const initial = '0123456789012345678901234567890123456789.01234567890123456789';
 | |
|       const expected = '012345678901234567890123456789';
 | |
|       assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
 | |
|     });
 | |
| 
 | |
|     it('handles a file with a normal extension', () => {
 | |
|       const initial = '01234567890123456789012345678901234567890123456789.jpg';
 | |
|       const expected = '012345678901234567890123.jpg';
 | |
|       assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('_getExportAttachmentFileName', () => {
 | |
|     it('uses original filename if attachment has one', () => {
 | |
|       const message = {
 | |
|         body: 'something',
 | |
|       };
 | |
|       const index = 0;
 | |
|       const attachment = {
 | |
|         fileName: 'blah.jpg',
 | |
|       };
 | |
|       const expected = 'blah.jpg';
 | |
| 
 | |
|       const actual = Signal.Backup._getExportAttachmentFileName(
 | |
|         message,
 | |
|         index,
 | |
|         attachment
 | |
|       );
 | |
|       assert.strictEqual(actual, expected);
 | |
|     });
 | |
| 
 | |
|     it('uses attachment id if no filename', () => {
 | |
|       const message = {
 | |
|         body: 'something',
 | |
|       };
 | |
|       const index = 0;
 | |
|       const attachment = {
 | |
|         id: '123',
 | |
|       };
 | |
|       const expected = '123';
 | |
| 
 | |
|       const actual = Signal.Backup._getExportAttachmentFileName(
 | |
|         message,
 | |
|         index,
 | |
|         attachment
 | |
|       );
 | |
|       assert.strictEqual(actual, expected);
 | |
|     });
 | |
| 
 | |
|     it('uses filename and contentType if available', () => {
 | |
|       const message = {
 | |
|         body: 'something',
 | |
|       };
 | |
|       const index = 0;
 | |
|       const attachment = {
 | |
|         id: '123',
 | |
|         contentType: 'image/jpeg',
 | |
|       };
 | |
|       const expected = '123.jpeg';
 | |
| 
 | |
|       const actual = Signal.Backup._getExportAttachmentFileName(
 | |
|         message,
 | |
|         index,
 | |
|         attachment
 | |
|       );
 | |
|       assert.strictEqual(actual, expected);
 | |
|     });
 | |
| 
 | |
|     it('handles strange contentType', () => {
 | |
|       const message = {
 | |
|         body: 'something',
 | |
|       };
 | |
|       const index = 0;
 | |
|       const attachment = {
 | |
|         id: '123',
 | |
|         contentType: 'something',
 | |
|       };
 | |
|       const expected = '123.something';
 | |
| 
 | |
|       const actual = Signal.Backup._getExportAttachmentFileName(
 | |
|         message,
 | |
|         index,
 | |
|         attachment
 | |
|       );
 | |
|       assert.strictEqual(actual, expected);
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('_getAnonymousAttachmentFileName', () => {
 | |
|     it('uses message id', () => {
 | |
|       const message = {
 | |
|         id: 'id-45',
 | |
|         body: 'something',
 | |
|       };
 | |
|       const index = 0;
 | |
|       const attachment = {
 | |
|         fileName: 'blah.jpg',
 | |
|       };
 | |
|       const expected = 'id-45';
 | |
| 
 | |
|       const actual = Signal.Backup._getAnonymousAttachmentFileName(
 | |
|         message,
 | |
|         index,
 | |
|         attachment
 | |
|       );
 | |
|       assert.strictEqual(actual, expected);
 | |
|     });
 | |
| 
 | |
|     it('appends index if it is above zero', () => {
 | |
|       const message = {
 | |
|         id: 'id-45',
 | |
|         body: 'something',
 | |
|       };
 | |
|       const index = 1;
 | |
|       const attachment = {
 | |
|         fileName: 'blah.jpg',
 | |
|       };
 | |
|       const expected = 'id-45-1';
 | |
| 
 | |
|       const actual = Signal.Backup._getAnonymousAttachmentFileName(
 | |
|         message,
 | |
|         index,
 | |
|         attachment
 | |
|       );
 | |
|       assert.strictEqual(actual, expected);
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('_getConversationDirName', () => {
 | |
|     it('uses name if available', () => {
 | |
|       const conversation = {
 | |
|         active_at: 123,
 | |
|         name: '0123456789012345678901234567890123456789',
 | |
|         id: 'id',
 | |
|       };
 | |
|       const expected = '123 (012345678901234567890123456789 id)';
 | |
|       assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
 | |
|     });
 | |
| 
 | |
|     it('uses just id if name is not available', () => {
 | |
|       const conversation = {
 | |
|         active_at: 123,
 | |
|         id: 'id',
 | |
|       };
 | |
|       const expected = '123 (id)';
 | |
|       assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
 | |
|     });
 | |
| 
 | |
|     it('uses inactive for missing active_at', () => {
 | |
|       const conversation = {
 | |
|         name: 'name',
 | |
|         id: 'id',
 | |
|       };
 | |
|       const expected = 'inactive (name id)';
 | |
|       assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('_getConversationLoggingName', () => {
 | |
|     it('uses plain id if conversation is private', () => {
 | |
|       const conversation = {
 | |
|         active_at: 123,
 | |
|         id: 'id',
 | |
|         type: 'private',
 | |
|       };
 | |
|       const expected = '123 (id)';
 | |
|       assert.strictEqual(
 | |
|         Signal.Backup._getConversationLoggingName(conversation),
 | |
|         expected
 | |
|       );
 | |
|     });
 | |
| 
 | |
|     it('uses just id if name is not available', () => {
 | |
|       const conversation = {
 | |
|         active_at: 123,
 | |
|         id: 'groupId',
 | |
|         type: 'group',
 | |
|       };
 | |
|       const expected = '123 ([REDACTED_GROUP]pId)';
 | |
|       assert.strictEqual(
 | |
|         Signal.Backup._getConversationLoggingName(conversation),
 | |
|         expected
 | |
|       );
 | |
|     });
 | |
| 
 | |
|     it('uses inactive for missing active_at', () => {
 | |
|       const conversation = {
 | |
|         id: 'id',
 | |
|         type: 'private',
 | |
|       };
 | |
|       const expected = 'inactive (id)';
 | |
|       assert.strictEqual(
 | |
|         Signal.Backup._getConversationLoggingName(conversation),
 | |
|         expected
 | |
|       );
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('end-to-end', () => {
 | |
|     it('exports then imports to produce the same data we started with', async () => {
 | |
|       const {
 | |
|         attachmentsPath,
 | |
|         fse,
 | |
|         glob,
 | |
|         path,
 | |
|         tmp,
 | |
|       } = window.test;
 | |
|       const {
 | |
|         upgradeMessageSchema,
 | |
|         loadAttachmentData,
 | |
|       } = window.Signal.Migrations;
 | |
| 
 | |
|       const key = new Uint8Array([
 | |
|         1, 3, 4, 5, 6, 7, 8, 11,
 | |
|         23, 34, 1, 34, 3, 5, 45, 45,
 | |
|         1, 3, 4, 5, 6, 7, 8, 11,
 | |
|         23, 34, 1, 34, 3, 5, 45, 45,
 | |
|       ]);
 | |
|       const attachmentsPattern = path.join(attachmentsPath, '**');
 | |
| 
 | |
|       const OUR_NUMBER = '+12025550000';
 | |
|       const CONTACT_ONE_NUMBER = '+12025550001';
 | |
| 
 | |
|       async function wrappedLoadAttachment(attachment) {
 | |
|         return _.omit(await loadAttachmentData(attachment), ['path']);
 | |
|       }
 | |
| 
 | |
|       async function clearAllData() {
 | |
|         await textsecure.storage.protocol.removeAllData();
 | |
|         await fse.emptyDir(attachmentsPath);
 | |
|       }
 | |
| 
 | |
|       function removeId(model) {
 | |
|         return _.omit(model, ['id']);
 | |
|       }
 | |
| 
 | |
|       const getUndefinedKeys = object =>
 | |
|         Object.entries(object)
 | |
|           .filter(([, value]) => value === undefined)
 | |
|           .map(([name]) => name);
 | |
|       const omitUndefinedKeys = object =>
 | |
|         _.omit(object, getUndefinedKeys(object));
 | |
| 
 | |
|       // We want to know which paths have two slashes, since that tells us which files
 | |
|       //   in the attachment fan-out are files vs. directories.
 | |
|       const TWO_SLASHES = /[^/]*\/[^/]*\/[^/]*/;
 | |
|       // On windows, attachmentsPath has a normal windows path format (\ separators), but
 | |
|       //   glob returns only /. We normalize to / separators for our manipulations.
 | |
|       const normalizedBase = attachmentsPath.replace(/\\/g, '/');
 | |
|       function removeDirs(dirs) {
 | |
|         return _.filter(dirs, (fullDir) => {
 | |
|           const dir = fullDir.replace(normalizedBase, '');
 | |
|           return TWO_SLASHES.test(dir);
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       function _mapQuotedAttachments(mapper) {
 | |
|         return async (message, context) => {
 | |
|           if (!message.quote) {
 | |
|             return message;
 | |
|           }
 | |
| 
 | |
|           const wrappedMapper = async (attachment) => {
 | |
|             if (!attachment || !attachment.thumbnail) {
 | |
|               return attachment;
 | |
|             }
 | |
| 
 | |
|             return Object.assign({}, attachment, {
 | |
|               thumbnail: await mapper(attachment.thumbnail, context),
 | |
|             });
 | |
|           };
 | |
| 
 | |
|           const quotedAttachments = (message.quote && message.quote.attachments) || [];
 | |
| 
 | |
|           return Object.assign({}, message, {
 | |
|             quote: Object.assign({}, message.quote, {
 | |
|               attachments: await Promise.all(quotedAttachments.map(wrappedMapper)),
 | |
|             }),
 | |
|           });
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       async function loadAllFilesFromDisk(message) {
 | |
|         const loadThumbnails = _mapQuotedAttachments((thumbnail) => {
 | |
|           // we want to be bulletproof to thumbnails without data
 | |
|           if (!thumbnail.path) {
 | |
|             return thumbnail;
 | |
|           }
 | |
| 
 | |
|           return wrappedLoadAttachment(thumbnail);
 | |
|         });
 | |
| 
 | |
|         const promises = (message.attachments || []).map(attachment =>
 | |
|           wrappedLoadAttachment(attachment));
 | |
| 
 | |
|         return Object.assign(
 | |
|           {},
 | |
|           await loadThumbnails(message),
 | |
|           {
 | |
|             attachments: await Promise.all(promises),
 | |
|           }
 | |
|         );
 | |
|       }
 | |
| 
 | |
|       let backupDir;
 | |
|       try {
 | |
|         const ATTACHMENT_COUNT = 2;
 | |
|         const MESSAGE_COUNT = 1;
 | |
|         const CONVERSATION_COUNT = 1;
 | |
| 
 | |
|         const messageWithAttachments = {
 | |
|           conversationId: CONTACT_ONE_NUMBER,
 | |
|           body: 'Totally!',
 | |
|           source: OUR_NUMBER,
 | |
|           received_at: 1524185933350,
 | |
|           timestamp: 1524185933350,
 | |
|           errors: [],
 | |
|           attachments: [{
 | |
|             contentType: 'image/gif',
 | |
|             fileName: 'sad_cat.gif',
 | |
|             data: new Uint8Array([
 | |
|               1, 2, 3, 4, 5, 6, 7, 8,
 | |
|               1, 2, 3, 4, 5, 6, 7, 8,
 | |
|               1, 2, 3, 4, 5, 6, 7, 8,
 | |
|               1, 2, 3, 4, 5, 6, 7, 8,
 | |
|             ]).buffer,
 | |
|           }],
 | |
|           hasAttachments: 1,
 | |
|           hasFileAttachments: undefined,
 | |
|           hasVisualMediaAttachments: 1,
 | |
|           quote: {
 | |
|             text: "Isn't it cute?",
 | |
|             author: CONTACT_ONE_NUMBER,
 | |
|             id: 12345678,
 | |
|             attachments: [{
 | |
|               contentType: 'audio/mp3',
 | |
|               fileName: 'song.mp3',
 | |
|             }, {
 | |
|               contentType: 'image/gif',
 | |
|               fileName: 'happy_cat.gif',
 | |
|               thumbnail: {
 | |
|                 contentType: 'image/png',
 | |
|                 data: new Uint8Array([
 | |
|                   2, 2, 3, 4, 5, 6, 7, 8,
 | |
|                   1, 2, 3, 4, 5, 6, 7, 8,
 | |
|                   1, 2, 3, 4, 5, 6, 7, 8,
 | |
|                   1, 2, 3, 4, 5, 6, 7, 8,
 | |
|                 ]).buffer,
 | |
|               },
 | |
|             }],
 | |
|           },
 | |
|         };
 | |
| 
 | |
|         console.log('Backup test: Clear all data');
 | |
|         await clearAllData();
 | |
| 
 | |
|         console.log('Backup test: Create models, save to db/disk');
 | |
|         const message = await upgradeMessageSchema(messageWithAttachments);
 | |
|         console.log({ message });
 | |
|         const messageModel = new Whisper.Message(message);
 | |
|         await window.wrapDeferred(messageModel.save());
 | |
| 
 | |
|         const conversation = {
 | |
|           active_at: 1524185933350,
 | |
|           color: 'orange',
 | |
|           expireTimer: 0,
 | |
|           id: CONTACT_ONE_NUMBER,
 | |
|           lastMessage: 'Heyo!',
 | |
|           name: 'Someone Somewhere',
 | |
|           profileAvatar: {
 | |
|             contentType: 'image/jpeg',
 | |
|             data: new Uint8Array([
 | |
|               3, 2, 3, 4, 5, 6, 7, 8,
 | |
|               1, 2, 3, 4, 5, 6, 7, 8,
 | |
|               1, 2, 3, 4, 5, 6, 7, 8,
 | |
|               1, 2, 3, 4, 5, 6, 7, 8,
 | |
|             ]).buffer,
 | |
|             size: 64,
 | |
|           },
 | |
|           profileKey: new Uint8Array([
 | |
|             4, 2, 3, 4, 5, 6, 7, 8,
 | |
|             1, 2, 3, 4, 5, 6, 7, 8,
 | |
|             1, 2, 3, 4, 5, 6, 7, 8,
 | |
|             1, 2, 3, 4, 5, 6, 7, 8,
 | |
|           ]).buffer,
 | |
|           profileName: 'Someone! 🤔',
 | |
|           profileSharing: true,
 | |
|           timestamp: 1524185933350,
 | |
|           tokens: [
 | |
|             'someone somewhere',
 | |
|             'someone',
 | |
|             'somewhere',
 | |
|             '2025550001',
 | |
|             '12025550001',
 | |
|           ],
 | |
|           type: 'private',
 | |
|           unreadCount: 0,
 | |
|           verified: 0,
 | |
|         };
 | |
|         console.log({ conversation });
 | |
|         const conversationModel = new Whisper.Conversation(conversation);
 | |
|         await window.wrapDeferred(conversationModel.save());
 | |
| 
 | |
|         console.log('Backup test: Ensure that all attachments were saved to disk');
 | |
|         const attachmentFiles = removeDirs(glob.sync(attachmentsPattern));
 | |
|         console.log({ attachmentFiles });
 | |
|         assert.strictEqual(ATTACHMENT_COUNT, attachmentFiles.length);
 | |
| 
 | |
|         console.log('Backup test: Export!');
 | |
|         backupDir = tmp.dirSync().name;
 | |
|         console.log({ backupDir });
 | |
|         await Signal.Backup.exportToDirectory(backupDir, { key });
 | |
| 
 | |
|         console.log('Backup test: Ensure that messages.zip exists');
 | |
|         const zipPath = path.join(backupDir, 'messages.zip');
 | |
|         const messageZipExists = fse.existsSync(zipPath);
 | |
|         assert.strictEqual(true, messageZipExists);
 | |
| 
 | |
|         console.log('Backup test: Ensure that all attachments made it to backup dir');
 | |
|         const backupAttachmentPattern = path.join(backupDir, 'attachments/*');
 | |
|         const backupAttachments = glob.sync(backupAttachmentPattern);
 | |
|         console.log({ backupAttachments });
 | |
|         assert.strictEqual(ATTACHMENT_COUNT, backupAttachments.length);
 | |
| 
 | |
|         console.log('Backup test: Clear all data');
 | |
|         await clearAllData();
 | |
| 
 | |
|         console.log('Backup test: Import!');
 | |
|         await Signal.Backup.importFromDirectory(backupDir, { key });
 | |
| 
 | |
|         console.log('Backup test: ensure that all attachments were imported');
 | |
|         const recreatedAttachmentFiles = removeDirs(glob.sync(attachmentsPattern));
 | |
|         console.log({ recreatedAttachmentFiles });
 | |
|         assert.strictEqual(ATTACHMENT_COUNT, recreatedAttachmentFiles.length);
 | |
|         assert.deepEqual(attachmentFiles, recreatedAttachmentFiles);
 | |
| 
 | |
|         console.log('Backup test: Check messages');
 | |
|         const messageCollection = new Whisper.MessageCollection();
 | |
|         await window.wrapDeferred(messageCollection.fetch());
 | |
|         assert.strictEqual(messageCollection.length, MESSAGE_COUNT);
 | |
|         const messageFromDB = removeId(messageCollection.at(0).attributes);
 | |
|         const expectedMessage = omitUndefinedKeys(message);
 | |
|         console.log({ messageFromDB, expectedMessage });
 | |
|         assert.deepEqual(
 | |
|           messageFromDB,
 | |
|           expectedMessage
 | |
|         );
 | |
| 
 | |
|         console.log('Backup test: Check that all attachments were successfully imported');
 | |
|         const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(messageFromDB);
 | |
|         const expectedMessageWithAttachments = omitUndefinedKeys(messageWithAttachments);
 | |
|         console.log({ messageWithAttachmentsFromDB, expectedMessageWithAttachments });
 | |
|         assert.deepEqual(
 | |
|           _.omit(messageWithAttachmentsFromDB, ['schemaVersion']),
 | |
|           expectedMessageWithAttachments
 | |
|         );
 | |
| 
 | |
|         console.log('Backup test: Check conversations');
 | |
|         const conversationCollection = new Whisper.ConversationCollection();
 | |
|         await window.wrapDeferred(conversationCollection.fetch());
 | |
|         assert.strictEqual(conversationCollection.length, CONVERSATION_COUNT);
 | |
| 
 | |
|         const conversationFromDB = conversationCollection.at(0).attributes;
 | |
|         console.log({ conversationFromDB, conversation });
 | |
|         assert.deepEqual(
 | |
|           conversationFromDB,
 | |
|           _.omit(conversation, ['profileAvatar'])
 | |
|         );
 | |
| 
 | |
|         console.log('Backup test: Clear all data');
 | |
|         await clearAllData();
 | |
| 
 | |
|         console.log('Backup test: Complete!');
 | |
|       } finally {
 | |
|         if (backupDir) {
 | |
|           console.log({ backupDir });
 | |
|           console.log('Deleting', backupDir);
 | |
|           await fse.remove(backupDir);
 | |
|         }
 | |
|       }
 | |
|     });
 | |
|   });
 | |
| });
 |