Files
Desktop/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts
2026-02-05 14:48:31 -05:00

489 lines
14 KiB
TypeScript

// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import fsExtra from 'fs-extra';
import { v4 as generateUuid } from 'uuid';
import { readdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { DataWriter } from '../sql/Client.preload.js';
import { missingCaseError } from '../util/missingCaseError.std.js';
import {
getAbsoluteAttachmentPath,
getAbsoluteDownloadsPath,
getAbsoluteDraftPath,
getAbsoluteMegaphoneImageFilePath,
} from '../util/migrations.preload.js';
import {
getDownloadsPath,
getDraftPath,
getAttachmentsPath,
} from '../windows/main/attachments.preload.js';
import { generateAci } from '../types/ServiceId.std.js';
import { IMAGE_JPEG, LONG_MESSAGE } from '../types/MIME.std.js';
import type { MessageAttributesType } from '../model-types.d.ts';
import type { RemoteMegaphoneId } from '../types/Megaphone.std.js';
const { emptyDir, ensureFile } = fsExtra;
type TestAttachmentTypes = 'attachment' | 'download' | 'draft' | 'megaphone';
function getAbsolutePath(path: string, type: TestAttachmentTypes) {
switch (type) {
case 'attachment':
return getAbsoluteAttachmentPath(path);
case 'download':
return getAbsoluteDownloadsPath(path);
case 'draft':
return getAbsoluteDraftPath(path);
case 'megaphone':
return getAbsoluteMegaphoneImageFilePath(path);
default:
throw missingCaseError(type);
}
}
async function writeFile(path: string, type: TestAttachmentTypes) {
await ensureFile(getAbsolutePath(path, type));
}
async function writeFiles(num: number, type: TestAttachmentTypes) {
for (let i = 0; i < num; i += 1) {
// eslint-disable-next-line no-await-in-loop
await writeFile(`${type}${i}`, type);
}
}
function listFiles(type: TestAttachmentTypes): Array<string> {
return readdirSync(dirname(getAbsolutePath('fakename', type)));
}
let attachmentIndex = 0;
let downloadIndex = 0;
describe('cleanupOrphanedAttachments', () => {
// TODO (DESKTOP-8613): stickers & badges
beforeEach(async () => {
await DataWriter.removeAll();
attachmentIndex = 0;
downloadIndex = 0;
await emptyDir(
getAttachmentsPath(window.SignalContext.config.userDataPath)
);
await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
await emptyDir(getDraftPath(window.SignalContext.config.userDataPath));
});
afterEach(async () => {
await emptyDir(
getAttachmentsPath(window.SignalContext.config.userDataPath)
);
await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
await emptyDir(getDraftPath(window.SignalContext.config.userDataPath));
});
function getAttachmentFilePath() {
attachmentIndex += 1;
return `attachment${attachmentIndex}`;
}
function getDownloadFilePath() {
downloadIndex += 1;
return `download${downloadIndex}`;
}
function composeAttachment() {
return {
contentType: IMAGE_JPEG,
size: 128,
path: getAttachmentFilePath(),
downloadPath: getDownloadFilePath(),
thumbnail: {
contentType: IMAGE_JPEG,
size: 128,
path: getAttachmentFilePath(),
},
screenshot: {
contentType: IMAGE_JPEG,
size: 128,
path: getAttachmentFilePath(),
},
thumbnailFromBackup: {
contentType: IMAGE_JPEG,
size: 128,
path: getAttachmentFilePath(),
},
};
}
function composeBodyAttachment() {
return {
contentType: LONG_MESSAGE,
size: 128,
path: getAttachmentFilePath(),
downloadPath: getDownloadFilePath(),
};
}
it('deletes paths if not referenced', async () => {
await writeFiles(2, 'attachment');
await writeFiles(2, 'draft');
await writeFiles(2, 'download');
assert.sameDeepMembers(listFiles('attachment'), [
'attachment0',
'attachment1',
]);
assert.sameDeepMembers(listFiles('draft'), ['draft0', 'draft1']);
assert.sameDeepMembers(listFiles('download'), ['download0', 'download1']);
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.sameDeepMembers(listFiles('attachment'), []);
assert.sameDeepMembers(listFiles('draft'), []);
assert.sameDeepMembers(listFiles('download'), []);
});
it('does not delete conversation avatar and profileAvatar paths', async () => {
await writeFiles(6, 'attachment');
await DataWriter.saveConversation({
id: generateUuid(),
type: 'private',
version: 2,
expireTimerVersion: 2,
avatar: {
path: 'attachment0',
},
profileAvatar: {
path: 'attachment1',
},
});
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.sameDeepMembers(listFiles('attachment'), [
'attachment0',
'attachment1',
]);
});
describe('message attachments', () => {
// Update these if more paths are added to composeMessageWithAllAttachments
const NUM_ATTACHMENT_FILES_IN_MESSAGE = 26;
const NUM_DOWNLOAD_FILES_IN_MESSAGE = 8;
function composeMessageWithAllAttachments(): MessageAttributesType {
return {
id: generateUuid(),
type: 'outgoing',
sent_at: Date.now(),
timestamp: Date.now(),
received_at: Date.now(),
conversationId: generateUuid(),
bodyAttachment: composeBodyAttachment(),
attachments: [composeAttachment(), composeAttachment()],
contact: [
{
avatar: {
isProfile: false,
avatar: composeAttachment(),
},
},
],
preview: [
{
url: 'url',
image: composeAttachment(),
},
],
editHistory: [
{
timestamp: Date.now(),
received_at: Date.now(),
bodyAttachment: composeBodyAttachment(),
},
],
quote: {
id: Date.now(),
isViewOnce: false,
referencedMessageNotFound: false,
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: composeAttachment(),
},
],
},
sticker: {
packId: 'packId',
stickerId: 42,
packKey: 'packKey',
data: composeAttachment(),
},
};
}
it('does not delete message attachments (including thumbnails, previews, avatars, etc.)', async () => {
await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment');
await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download');
await DataWriter.saveMessage(composeMessageWithAllAttachments(), {
ourAci: generateAci(),
forceSave: true,
postSaveUpdates: () => Promise.resolve(),
});
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);
const attachmentFiles = listFiles('attachment');
const downloadFiles = listFiles('download');
assert.strictEqual(
attachmentFiles.length,
NUM_ATTACHMENT_FILES_IN_MESSAGE
);
assert.sameDeepMembers(
attachmentFiles,
new Array(attachmentIndex)
.fill(null)
.map((_, idx) => `attachment${idx + 1}`)
);
assert.strictEqual(downloadFiles.length, NUM_DOWNLOAD_FILES_IN_MESSAGE);
assert.sameDeepMembers(
downloadFiles,
new Array(downloadIndex)
.fill(null)
.map((_, idx) => `download${idx + 1}`)
);
});
it('does not delete any protected attachment paths', async () => {
await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment');
await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download');
await DataWriter.saveMessage(composeMessageWithAllAttachments(), {
ourAci: generateAci(),
forceSave: true,
postSaveUpdates: () => Promise.resolve(),
});
await DataWriter._protectAttachmentPathFromDeletion(
`attachment${attachmentIndex + 1}`
);
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
const attachmentFiles = listFiles('attachment');
assert.strictEqual(
attachmentFiles.length,
NUM_ATTACHMENT_FILES_IN_MESSAGE + 1
);
assert.sameDeepMembers(
attachmentFiles,
new Array(attachmentIndex + 1)
.fill(null)
.map((_, idx) => `attachment${idx + 1}`)
);
});
it('works with non-normalized message attachments', async () => {
await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment');
await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download');
await DataWriter.saveMessage(composeMessageWithAllAttachments(), {
ourAci: generateAci(),
forceSave: true,
// Save one with attachments not normalized
_testOnlyAvoidNormalizingAttachments: true,
postSaveUpdates: () => Promise.resolve(),
});
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);
const attachmentFiles = listFiles('attachment');
const downloadFiles = listFiles('download');
assert.strictEqual(
attachmentFiles.length,
NUM_ATTACHMENT_FILES_IN_MESSAGE
);
assert.sameDeepMembers(
attachmentFiles,
new Array(attachmentIndex)
.fill(null)
.map((_, idx) => `attachment${idx + 1}`)
);
assert.strictEqual(downloadFiles.length, NUM_DOWNLOAD_FILES_IN_MESSAGE);
assert.sameDeepMembers(
downloadFiles,
new Array(downloadIndex)
.fill(null)
.map((_, idx) => `download${idx + 1}`)
);
});
it('will NOT delete copied quote attachments if there is at least one strong reference', async () => {
await writeFiles(10, 'attachment');
const quotedMessage = {
id: generateUuid(),
type: 'outgoing',
sent_at: Date.now(),
timestamp: Date.now(),
received_at: Date.now(),
conversationId: generateUuid(),
attachments: [
{
contentType: IMAGE_JPEG,
size: 128,
path: 'attachment1',
thumbnail: {
contentType: IMAGE_JPEG,
size: 42,
// strong reference
path: 'attachment2',
},
},
],
} as const;
const quotingMessage = {
id: generateUuid(),
type: 'outgoing',
sent_at: Date.now(),
timestamp: Date.now(),
received_at: Date.now(),
conversationId: generateUuid(),
quote: {
id: quotedMessage.sent_at,
isViewOnce: false,
referencedMessageNotFound: false,
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: {
contentType: IMAGE_JPEG,
size: 42,
// weak (copied) reference
path: 'attachment2',
copied: true,
},
},
],
},
} as const;
// Make sure we constructed the test correctly: both attachments reference the same
// path on disk
assert.strictEqual(
quotedMessage.attachments[0].thumbnail.path,
quotingMessage.quote.attachments[0].thumbnail.path
);
await DataWriter.saveMessages([quotedMessage, quotingMessage], {
ourAci: generateAci(),
forceSave: true,
postSaveUpdates: () => Promise.resolve(),
});
await DataWriter.cleanupOrphanedAttachments({ _block: true });
const attachmentFilesLeftOnDisk = listFiles('attachment');
assert.strictEqual(attachmentFilesLeftOnDisk.length, 2);
assert.sameDeepMembers(attachmentFilesLeftOnDisk, [
'attachment1',
'attachment2',
]);
});
it('will delete quote attachments if there are only weak references', async () => {
await writeFiles(10, 'attachment');
const quotingMessage = {
id: generateUuid(),
type: 'outgoing',
sent_at: Date.now(),
timestamp: Date.now(),
received_at: Date.now(),
conversationId: generateUuid(),
quote: {
id: Date.now(),
isViewOnce: false,
referencedMessageNotFound: false,
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: {
contentType: IMAGE_JPEG,
size: 42,
path: 'attachment1',
copied: true,
},
},
],
},
} as const;
await DataWriter.saveMessage(quotingMessage, {
ourAci: generateAci(),
forceSave: true,
postSaveUpdates: () => Promise.resolve(),
});
await DataWriter.cleanupOrphanedAttachments({ _block: true });
const attachmentFilesLeftOnDisk = listFiles('attachment');
assert.strictEqual(attachmentFilesLeftOnDisk.length, 0);
});
});
it('does not delete megaphone image paths', async () => {
// Write 2 files so 1 is orphaned
await writeFiles(2, 'megaphone');
await DataWriter.createMegaphone({
id: generateUuid() as RemoteMegaphoneId,
desktopMinVersion: '1.0.0',
priority: 1,
dontShowBeforeEpochMs: 0,
dontShowAfterEpochMs: Date.now() + 9001,
showForNumberOfDays: 7,
primaryCtaId: null,
secondaryCtaId: null,
primaryCtaData: null,
secondaryCtaData: null,
conditionalId: null,
title: 'a',
body: 'b',
primaryCtaText: null,
secondaryCtaText: null,
imagePath: 'megaphone0',
localeFetched: 'en',
shownAt: null,
snoozedAt: null,
snoozeCount: 0,
isFinished: false,
});
await DataWriter.cleanupOrphanedAttachments({ _block: true });
// Only the file associated with the megaphone is retained
assert.sameDeepMembers(listFiles('megaphone'), ['megaphone0']);
});
});