// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import createDebug from 'debug'; import { assert } from 'chai'; import { expect } from 'playwright/test'; import { type PrimaryDevice, type Proto, StorageState, } from '@signalapp/mock-server'; import { join } from 'node:path'; import { access, readFile } from 'node:fs/promises'; import { v4 } from 'uuid'; import type { App } from '../playwright.node.js'; import { Bootstrap } from '../bootstrap.node.js'; import { getMessageInTimelineByTimestamp, getTimelineMessageWithText, sendMessageWithAttachments, sendTextMessage, } from '../helpers.node.js'; import * as durations from '../../util/durations/index.std.js'; import { strictAssert } from '../../util/assert.std.js'; import { IMAGE_PNG, VIDEO_MP4 } from '../../types/MIME.std.js'; import { toBase64 } from '../../Bytes.std.js'; import type { AttachmentType } from '../../types/Attachment.std.js'; export const debug = createDebug('mock:test:attachments'); const CAT_PATH = join( __dirname, '..', '..', '..', 'fixtures', 'cat-screenshot.png' ); const VIDEO_PATH = join( __dirname, '..', '..', '..', 'fixtures', 'ghost-kitty.mp4' ); describe('attachments', function (this: Mocha.Suite) { this.timeout(durations.MINUTE); let bootstrap: Bootstrap; let app: App; let pinned: PrimaryDevice; beforeEach(async () => { bootstrap = new Bootstrap(); await bootstrap.init(); let state = StorageState.getEmpty(); const { phone, contacts } = bootstrap; [pinned] = contacts; state = state.addContact(pinned, { identityKey: pinned.publicKey.serialize(), profileKey: pinned.profileKey.serialize(), whitelisted: true, }); state = state.pin(pinned); await phone.setStorageState(state); app = await bootstrap.link(); }); afterEach(async function (this: Mocha.Context) { if (!bootstrap) { return; } await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); it('can upload attachment to CDN3 and download incoming attachment', async () => { const page = await app.getWindow(); await page.getByTestId(pinned.device.aci).click(); const { attachments: [attachmentCat], } = await sendMessageWithAttachments(page, pinned, 'This is my cat', [ CAT_PATH, ]); const Message = getTimelineMessageWithText(page, 'This is my cat'); const MessageSent = Message.locator( '.module-message__metadata__status-icon--sent' ); debug('waiting for send'); await MessageSent.waitFor(); const timestamp = await Message.getAttribute('data-testid'); strictAssert(timestamp, 'timestamp must exist'); const sentMessage = ( await app.getMessagesBySentAt(parseInt(timestamp, 10)) )[0]; strictAssert(sentMessage, 'message exists in DB'); const sentAttachment = sentMessage.attachments?.[0]; // For this test, just send back the same attachment that was uploaded to test a // round-trip const incomingTimestamp = Date.now(); await sendTextMessage({ from: pinned, to: bootstrap.desktop, desktop: bootstrap.desktop, text: 'Wait, that is MY cat!', attachments: [attachmentCat], timestamp: incomingTimestamp, }); await expect( getMessageInTimelineByTimestamp(page, incomingTimestamp).locator( 'img.module-image__image' ) ).toBeVisible(); const incomingMessage = ( await app.getMessagesBySentAt(incomingTimestamp) )[0]; strictAssert(incomingMessage, 'message exists in DB'); const incomingAttachment = incomingMessage.attachments?.[0]; assert.strictEqual(incomingAttachment?.key, sentAttachment?.key); assert.strictEqual(incomingAttachment?.digest, sentAttachment?.digest); }); it('can download videos with incrementalMac and is resilient to bad incrementalMacs', async () => { const { desktop } = bootstrap; const page = await app.getWindow(); await page.getByTestId(pinned.device.aci).click(); const plaintextVideo = await readFile(VIDEO_PATH); const videoPointer1 = await bootstrap.encryptAndStoreAttachmentOnCDN( plaintextVideo, VIDEO_MP4 ); const videoPointer2 = await bootstrap.encryptAndStoreAttachmentOnCDN( plaintextVideo, VIDEO_MP4 ); const incrementalTimestamp = Date.now(); const badIncrementalTimestamp = incrementalTimestamp + 1; await sendTextMessage({ from: pinned, to: desktop, desktop, text: 'video with good incrementalMac', attachments: [videoPointer1], timestamp: incrementalTimestamp, }); await sendTextMessage({ from: pinned, to: desktop, desktop, text: 'video with bad incrementalMac', attachments: [ { ...videoPointer2, chunkSize: (videoPointer2.chunkSize ?? 42) + 1 }, ], timestamp: badIncrementalTimestamp, }); await expect( getMessageInTimelineByTimestamp(page, incrementalTimestamp).locator( 'img.module-image__image' ) ).toBeVisible(); await expect( getMessageInTimelineByTimestamp(page, badIncrementalTimestamp).locator( 'img.module-image__image' ) ).toBeVisible(); // goodIncrementalMac preserved { const messageInDB = ( await app.getMessagesBySentAt(incrementalTimestamp) )[0]; strictAssert(messageInDB, 'message exists in DB'); const attachmentInDB = messageInDB.attachments?.[0]; strictAssert(videoPointer1.incrementalMac, 'must exist'); strictAssert(videoPointer1.chunkSize, 'must exist'); assert.strictEqual( attachmentInDB?.incrementalMac, toBase64(videoPointer1.incrementalMac) ); assert.strictEqual(attachmentInDB?.chunkSize, videoPointer1.chunkSize); } // badIncrementalMac removed { const messageInDB = ( await app.getMessagesBySentAt(badIncrementalTimestamp) )[0]; strictAssert(messageInDB, 'message exists in DB'); const attachmentInDB = messageInDB.attachments?.[0]; strictAssert(videoPointer2.incrementalMac, 'must exist'); strictAssert(videoPointer2.chunkSize, 'must exist'); assert.strictEqual(attachmentInDB?.incrementalMac, undefined); assert.strictEqual(attachmentInDB?.chunkSize, undefined); } }); it('reuses attachment file when receiving and sending same video and deletes only when all references removed', async () => { const page = await app.getWindow(); const { desktop, phone } = bootstrap; await page.getByTestId(pinned.device.aci).click(); const plaintextVideo = await readFile(VIDEO_PATH); const ciphertextVideo = await bootstrap.encryptAndStoreAttachmentOnCDN( plaintextVideo, VIDEO_MP4 ); const ciphertextVideo2 = await bootstrap.encryptAndStoreAttachmentOnCDN( plaintextVideo, VIDEO_MP4 ); const phoneTimestamp = bootstrap.getTimestamp(); const friendTimestamp = bootstrap.getTimestamp(); debug('receiving attachment from primary'); await page.getByTestId(pinned.device.aci).click(); await sendTextMessage({ from: phone, to: pinned, text: 'from phone', desktop, attachments: [{ ...ciphertextVideo, width: 568, height: 320 }], timestamp: phoneTimestamp, }); await getMessageInTimelineByTimestamp(page, phoneTimestamp) .locator('.module-image--loaded') .waitFor(); debug('receiving same attachment from contact'); await page.getByTestId(pinned.device.aci).click(); await sendTextMessage({ from: pinned, to: desktop, text: 'from friend', desktop, attachments: [{ ...ciphertextVideo2, width: 568, height: 320 }], timestamp: friendTimestamp, }); await getMessageInTimelineByTimestamp(page, friendTimestamp) .locator('.module-image--loaded') .waitFor(); debug('sending same attachment from desktop'); const { timestamp: sentTimestamp } = await sendMessageWithAttachments( page, pinned, 'from desktop', [VIDEO_PATH] ); strictAssert(sentTimestamp, 'outgoing timestamp must exist'); const phoneDBMessage = (await app.getMessagesBySentAt(phoneTimestamp))[0]; const phoneDBAttachment: AttachmentType = phoneDBMessage.attachments?.[0]; const friendDBMessage = (await app.getMessagesBySentAt(friendTimestamp))[0]; const friendDBAttachment: AttachmentType = friendDBMessage.attachments?.[0]; const sentDBMessage = (await app.getMessagesBySentAt(sentTimestamp))[0]; const sentDBAttachment: AttachmentType = sentDBMessage.attachments?.[0]; strictAssert(phoneDBAttachment, 'outgoing sync message exists in DB'); strictAssert(friendDBAttachment, 'incoming message exists in DB'); strictAssert(sentDBAttachment, 'sent message exists in DB'); const { path } = phoneDBAttachment; const thumbnailPath = phoneDBAttachment.thumbnail?.path; strictAssert(path, 'path exists'); strictAssert(thumbnailPath, 'thumbnail path exists'); debug( 'checking that incoming and outgoing messages deduplicated data on disk' ); assert.strictEqual(phoneDBAttachment.path, path); assert.strictEqual(friendDBAttachment.path, path); assert.strictEqual(sentDBAttachment.path, path); assert.strictEqual(phoneDBAttachment.thumbnail?.path, thumbnailPath); assert.strictEqual(friendDBAttachment.thumbnail?.path, thumbnailPath); assert.strictEqual(sentDBAttachment.thumbnail?.path, thumbnailPath); debug('deleting two of the messages'); const sentMessage = getMessageInTimelineByTimestamp(page, sentTimestamp); await sentMessage.click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete for me' }).click(); await expect(sentMessage).not.toBeVisible(); const friendMessage = getMessageInTimelineByTimestamp( page, friendTimestamp ); await friendMessage.click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete for me' }).click(); await expect(friendMessage).not.toBeVisible(); await app.waitForMessageToBeCleanedUp(sentDBMessage.id); await app.waitForMessageToBeCleanedUp(friendDBMessage.id); assert.strictEqual( (await app.getMessagesBySentAt(friendTimestamp)).length, 0 ); assert.strictEqual( (await app.getMessagesBySentAt(sentTimestamp)).length, 0 ); // Path still exists! await access(bootstrap.getAbsoluteAttachmentPath(path)); debug('delete last of messages'); const phoneMessage = getMessageInTimelineByTimestamp(page, phoneTimestamp); await phoneMessage.click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete for me' }).click(); await expect(phoneMessage).not.toBeVisible(); await app.waitForMessageToBeCleanedUp(phoneDBMessage.id); assert.strictEqual( (await app.getMessagesBySentAt(phoneTimestamp)).length, 0 ); await expect( access(bootstrap.getAbsoluteAttachmentPath(path)) ).rejects.toMatchObject({ code: 'ENOENT', }); }); it('reencodes images to same data and reuses same file when sending', async () => { const page = await app.getWindow(); await page.getByTestId(pinned.device.aci).click(); const { timestamp: firstTimestamp } = await sendMessageWithAttachments( page, pinned, 'first send', [CAT_PATH] ); const { timestamp: secondTimestamp } = await sendMessageWithAttachments( page, pinned, 'second send', [CAT_PATH] ); const firstAttachment: AttachmentType = ( await app.getMessagesBySentAt(firstTimestamp) )[0].attachments?.[0]; const secondAttachment: AttachmentType = ( await app.getMessagesBySentAt(secondTimestamp) )[0].attachments?.[0]; strictAssert(firstAttachment, 'firstAttachment exists in DB'); strictAssert(secondAttachment, 'secondAttachment exists in DB'); debug( 'checking that incoming and outgoing messages deduplicated data on disk' ); assert.strictEqual(firstAttachment.path, secondAttachment.path); assert.strictEqual(firstAttachment.localKey, secondAttachment.localKey); assert.strictEqual( firstAttachment.thumbnail?.path, secondAttachment.thumbnail?.path ); }); it('reuses recent CDN upload info', async () => { const { desktop } = bootstrap; const page = await app.getWindow(); await page.getByTestId(pinned.device.aci).click(); const plaintextVideo = await readFile(VIDEO_PATH); const recentPointer: Proto.AttachmentPointer.Params = { ...(await bootstrap.encryptAndStoreAttachmentOnCDN( plaintextVideo, VIDEO_MP4 )), clientUuid: Buffer.from(v4(), 'utf8'), uploadTimestamp: BigInt(Date.now() - 2 * durations.DAY), fileName: 'incoming filename', }; const plaintextCat = await readFile(CAT_PATH); const stalePointer: Proto.AttachmentPointer.Params = { ...(await bootstrap.encryptAndStoreAttachmentOnCDN( plaintextCat, IMAGE_PNG )), uploadTimestamp: BigInt(Date.now() - 4 * durations.DAY), }; const incomingVideoTimestamp = bootstrap.getTimestamp(); await sendTextMessage({ from: pinned, to: desktop, desktop, text: 'incoming video', attachments: [recentPointer], timestamp: incomingVideoTimestamp, }); const incomingCatTimestamp = bootstrap.getTimestamp(); await sendTextMessage({ from: pinned, to: desktop, desktop, text: 'incoming cat', attachments: [stalePointer], timestamp: incomingCatTimestamp, }); await expect( getMessageInTimelineByTimestamp(page, incomingVideoTimestamp).locator( 'img.module-image__image' ) ).toBeVisible(); await expect( getMessageInTimelineByTimestamp(page, incomingCatTimestamp).locator( 'img.module-image__image' ) ).toBeVisible(); const { attachments: sentVideoAttachments } = await sendMessageWithAttachments(page, pinned, 'sending same video', [ VIDEO_PATH, ]); const { attachments: sentCatAttachments } = await sendMessageWithAttachments(page, pinned, 'sending same cat', [ CAT_PATH, ]); const sentVideo = sentVideoAttachments[0]; assert.deepStrictEqual( sentVideo.attachmentIdentifier, recentPointer.attachmentIdentifier ); assert.deepStrictEqual(sentVideo.cdnNumber, recentPointer.cdnNumber); assert.deepStrictEqual(sentVideo.key, recentPointer.key); assert.deepStrictEqual(sentVideo.digest, recentPointer.digest); assert.deepStrictEqual( sentVideo.incrementalMac, recentPointer.incrementalMac ); assert.deepStrictEqual(sentVideo.chunkSize, recentPointer.chunkSize); assert.notDeepEqual(sentVideo.clientUuid, recentPointer.clientUuid); assert.notDeepEqual(sentVideo.fileName, recentPointer.fileName); const sentCat = sentCatAttachments[0]; assert.notDeepEqual( sentCat.attachmentIdentifier, stalePointer.attachmentIdentifier ); assert.notDeepEqual(sentCat.key, stalePointer.key); assert.notDeepEqual(sentCat.digest, stalePointer.digest); }); });