Export/import attachments in integration tests
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
parent
850b78042b
commit
6859b1a220
21 changed files with 292 additions and 116 deletions
|
@ -4699,6 +4699,10 @@
|
|||
"messageformat": "Downloading {currentSize} of {totalSize} ({fractionComplete, number, percent})...",
|
||||
"description": "Hint under the progressbar in the backup import screen"
|
||||
},
|
||||
"icu:BackupImportScreen__progressbar-hint--processing": {
|
||||
"messageformat": "Finalizing message transfer...",
|
||||
"description": "Hint under the progressbar in the backup import screen shown after the backup file is downloaded and while backup data is being processed"
|
||||
},
|
||||
"icu:BackupImportScreen__progressbar-hint--preparing": {
|
||||
"messageformat": "Preparing to download...",
|
||||
"description": "Hint under the progressbar in the backup import screen when download size is not yet known"
|
||||
|
|
8
package-lock.json
generated
8
package-lock.json
generated
|
@ -126,7 +126,7 @@
|
|||
"@indutny/parallel-prettier": "3.0.0",
|
||||
"@indutny/rezip-electron": "1.3.1",
|
||||
"@indutny/symbolicate-mac": "2.3.0",
|
||||
"@signalapp/mock-server": "8.0.1",
|
||||
"@signalapp/mock-server": "8.1.1",
|
||||
"@storybook/addon-a11y": "8.1.11",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
|
@ -7296,9 +7296,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@signalapp/mock-server": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.0.1.tgz",
|
||||
"integrity": "sha512-qfyBOtMmQ3RF3Kig0DTafrxUx8MZ2hB+5H6ZJVV1lQS022U6bOHiVjZyAJ0uZgU98FJZIXlT/zWJ24kFl6/pGQ==",
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.1.1.tgz",
|
||||
"integrity": "sha512-TlQpOyUYnDBV7boxyaLDaeGTN5WIn4trbF+9rq4+6rXfpzIBnf2A4Y1fzFRVL9F9/F4ZEPtrv+V3oplNrfoZ9w==",
|
||||
"dev": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
|
|
@ -210,7 +210,7 @@
|
|||
"@indutny/parallel-prettier": "3.0.0",
|
||||
"@indutny/rezip-electron": "1.3.1",
|
||||
"@indutny/symbolicate-mac": "2.3.0",
|
||||
"@signalapp/mock-server": "8.0.1",
|
||||
"@signalapp/mock-server": "8.1.1",
|
||||
"@storybook/addon-a11y": "8.1.11",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
|
|
|
@ -110,17 +110,22 @@ export type HardcodedIVForEncryptionType =
|
|||
digestToMatch: Uint8Array;
|
||||
};
|
||||
|
||||
type EncryptAttachmentV2PropsType = {
|
||||
type EncryptAttachmentV2OptionsType = Readonly<{
|
||||
dangerousIv?: HardcodedIVForEncryptionType;
|
||||
dangerousTestOnlySkipPadding?: boolean;
|
||||
getAbsoluteAttachmentPath: (relativePath: string) => string;
|
||||
keys: Readonly<Uint8Array>;
|
||||
needIncrementalMac: boolean;
|
||||
plaintext: PlaintextSourceType;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type EncryptAttachmentV2ToDiskOptionsType =
|
||||
EncryptAttachmentV2OptionsType &
|
||||
Readonly<{
|
||||
getAbsoluteAttachmentPath: (relativePath: string) => string;
|
||||
}>;
|
||||
|
||||
export async function encryptAttachmentV2ToDisk(
|
||||
args: EncryptAttachmentV2PropsType
|
||||
args: EncryptAttachmentV2ToDiskOptionsType
|
||||
): Promise<EncryptedAttachmentV2 & { path: string }> {
|
||||
// Create random output file
|
||||
const relativeTargetPath = getRelativePath(createName());
|
||||
|
@ -152,7 +157,7 @@ export async function encryptAttachmentV2({
|
|||
needIncrementalMac,
|
||||
plaintext,
|
||||
sink,
|
||||
}: EncryptAttachmentV2PropsType & {
|
||||
}: EncryptAttachmentV2OptionsType & {
|
||||
sink?: Writable;
|
||||
}): Promise<EncryptedAttachmentV2> {
|
||||
const logId = 'encryptAttachmentV2';
|
||||
|
@ -580,7 +585,6 @@ export async function decryptAndReencryptLocally(
|
|||
const [result] = await Promise.all([
|
||||
decryptAttachmentV2ToSink(options, passthrough),
|
||||
await encryptAttachmentV2({
|
||||
getAbsoluteAttachmentPath: options.getAbsoluteAttachmentPath,
|
||||
keys,
|
||||
needIncrementalMac: false,
|
||||
plaintext: {
|
||||
|
|
2
ts/CI.ts
2
ts/CI.ts
|
@ -10,6 +10,7 @@ import * as log from './logging/log';
|
|||
import { explodePromise } from './util/explodePromise';
|
||||
import { AccessType, ipcInvoke } from './sql/channels';
|
||||
import { backupsService } from './services/backups';
|
||||
import { AttachmentBackupManager } from './jobs/AttachmentBackupManager';
|
||||
import { SECOND } from './util/durations';
|
||||
import { isSignalRoute } from './util/signalRoutes';
|
||||
import { strictAssert } from './util/assert';
|
||||
|
@ -168,6 +169,7 @@ export function getCI({ deviceName }: GetCIOptionsType): CIType {
|
|||
|
||||
async function uploadBackup() {
|
||||
await backupsService.upload();
|
||||
await AttachmentBackupManager.waitForIdle();
|
||||
}
|
||||
|
||||
function unlink() {
|
||||
|
|
|
@ -1604,8 +1604,9 @@ export async function startApp(): Promise<void> {
|
|||
// Download backup before enabling request handler and storage service
|
||||
try {
|
||||
await backupsService.download({
|
||||
onProgress: (currentBytes, totalBytes) => {
|
||||
onProgress: (backupStep, currentBytes, totalBytes) => {
|
||||
window.reduxActions.installer.updateBackupImportProgress({
|
||||
backupStep,
|
||||
currentBytes,
|
||||
totalBytes,
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@ import React from 'react';
|
|||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
import { InstallScreenBackupStep } from '../../types/InstallScreen';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import type { PropsType } from './InstallScreenBackupImportStep';
|
||||
import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep';
|
||||
|
@ -27,25 +28,36 @@ const Template: StoryFn<PropsType> = (args: PropsType) => (
|
|||
|
||||
export const NoBytes = Template.bind({});
|
||||
NoBytes.args = {
|
||||
backupStep: InstallScreenBackupStep.Download,
|
||||
currentBytes: undefined,
|
||||
totalBytes: undefined,
|
||||
};
|
||||
|
||||
export const Bytes = Template.bind({});
|
||||
Bytes.args = {
|
||||
backupStep: InstallScreenBackupStep.Download,
|
||||
currentBytes: 500 * 1024,
|
||||
totalBytes: 1024 * 1024,
|
||||
};
|
||||
|
||||
export const Full = Template.bind({});
|
||||
Full.args = {
|
||||
backupStep: InstallScreenBackupStep.Download,
|
||||
currentBytes: 1024,
|
||||
totalBytes: 1024,
|
||||
};
|
||||
|
||||
export const Error = Template.bind({});
|
||||
Error.args = {
|
||||
backupStep: InstallScreenBackupStep.Download,
|
||||
currentBytes: 500 * 1024,
|
||||
totalBytes: 1024 * 1024,
|
||||
hasError: true,
|
||||
};
|
||||
|
||||
export const Processing = Template.bind({});
|
||||
Processing.args = {
|
||||
backupStep: InstallScreenBackupStep.Process,
|
||||
currentBytes: 500 * 1024,
|
||||
totalBytes: 1024 * 1024,
|
||||
};
|
||||
|
|
|
@ -4,18 +4,21 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { InstallScreenBackupStep } from '../../types/InstallScreen';
|
||||
import { formatFileSize } from '../../util/formatFileSize';
|
||||
import { TitlebarDragArea } from '../TitlebarDragArea';
|
||||
import { ProgressBar } from '../ProgressBar';
|
||||
import { ConfirmationDialog } from '../ConfirmationDialog';
|
||||
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
|
||||
import { roundFractionForProgressBar } from '../../util/numbers';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
// We can't always use destructuring assignment because of the complexity of this props
|
||||
// type.
|
||||
|
||||
export type PropsType = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
backupStep: InstallScreenBackupStep;
|
||||
currentBytes?: number;
|
||||
totalBytes?: number;
|
||||
hasError?: boolean;
|
||||
|
@ -25,6 +28,7 @@ export type PropsType = Readonly<{
|
|||
|
||||
export function InstallScreenBackupImportStep({
|
||||
i18n,
|
||||
backupStep,
|
||||
currentBytes,
|
||||
totalBytes,
|
||||
hasError,
|
||||
|
@ -66,26 +70,33 @@ export function InstallScreenBackupImportStep({
|
|||
}, [onRetry]);
|
||||
|
||||
let progress: JSX.Element;
|
||||
let isCancelPossible = true;
|
||||
if (currentBytes != null && totalBytes != null) {
|
||||
isCancelPossible = currentBytes !== totalBytes;
|
||||
|
||||
const fractionComplete = roundFractionForProgressBar(
|
||||
currentBytes / totalBytes
|
||||
);
|
||||
|
||||
let hint: string;
|
||||
if (backupStep === InstallScreenBackupStep.Download) {
|
||||
hint = i18n('icu:BackupImportScreen__progressbar-hint', {
|
||||
currentSize: formatFileSize(currentBytes),
|
||||
totalSize: formatFileSize(totalBytes),
|
||||
fractionComplete,
|
||||
});
|
||||
} else if (backupStep === InstallScreenBackupStep.Process) {
|
||||
hint = i18n('icu:BackupImportScreen__progressbar-hint--processing');
|
||||
} else {
|
||||
throw missingCaseError(backupStep);
|
||||
}
|
||||
|
||||
progress = (
|
||||
<>
|
||||
<ProgressBar
|
||||
key={backupStep}
|
||||
fractionComplete={fractionComplete}
|
||||
isRTL={i18n.getLocaleDirection() === 'rtl'}
|
||||
/>
|
||||
<div className="InstallScreenBackupImportStep__progressbar-hint">
|
||||
{i18n('icu:BackupImportScreen__progressbar-hint', {
|
||||
currentSize: formatFileSize(currentBytes),
|
||||
totalSize: formatFileSize(totalBytes),
|
||||
fractionComplete,
|
||||
})}
|
||||
{hint}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -93,6 +104,7 @@ export function InstallScreenBackupImportStep({
|
|||
progress = (
|
||||
<>
|
||||
<ProgressBar
|
||||
key={backupStep}
|
||||
fractionComplete={0}
|
||||
isRTL={i18n.getLocaleDirection() === 'rtl'}
|
||||
/>
|
||||
|
@ -119,7 +131,7 @@ export function InstallScreenBackupImportStep({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isCancelPossible && (
|
||||
{backupStep === InstallScreenBackupStep.Download && (
|
||||
<button
|
||||
className="InstallScreenBackupImportStep__cancel"
|
||||
type="button"
|
||||
|
|
|
@ -159,6 +159,10 @@ export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobT
|
|||
static async addJob(newJob: CoreAttachmentBackupJobType): Promise<void> {
|
||||
return AttachmentBackupManager.instance.addJob(newJob);
|
||||
}
|
||||
|
||||
static async waitForIdle(): Promise<void> {
|
||||
return AttachmentBackupManager.instance.waitForIdle();
|
||||
}
|
||||
}
|
||||
|
||||
function getJobId(job: CoreAttachmentBackupJobType): string {
|
||||
|
|
|
@ -77,6 +77,7 @@ export abstract class JobManager<CoreJobType> {
|
|||
private jobCompletePromises: Map<string, ExplodePromiseResultType<void>> =
|
||||
new Map();
|
||||
private tickTimeout: NodeJS.Timeout | null = null;
|
||||
private idleCallbacks = new Array<() => void>();
|
||||
|
||||
protected logPrefix = 'JobManager';
|
||||
public tickInterval = DEFAULT_TICK_INTERVAL;
|
||||
|
@ -106,6 +107,14 @@ export abstract class JobManager<CoreJobType> {
|
|||
);
|
||||
}
|
||||
|
||||
async waitForIdle(): Promise<void> {
|
||||
if (this.activeJobs.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => this.idleCallbacks.push(resolve));
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
clearTimeoutIfNecessary(this.tickTimeout);
|
||||
this.tickTimeout = null;
|
||||
|
@ -233,6 +242,13 @@ export abstract class JobManager<CoreJobType> {
|
|||
});
|
||||
|
||||
if (nextJobs.length === 0) {
|
||||
if (this.idleCallbacks.length > 0) {
|
||||
const callbacks = this.idleCallbacks;
|
||||
this.idleCallbacks = [];
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
|
|||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { HOUR } from '../../util/durations';
|
||||
import { CipherType, HashType } from '../../types/Crypto';
|
||||
import { InstallScreenBackupStep } from '../../types/InstallScreen';
|
||||
import * as Errors from '../../types/errors';
|
||||
import { HTTPError } from '../../textsecure/Errors';
|
||||
import { constantTimeEqual } from '../../Crypto';
|
||||
|
@ -36,7 +37,7 @@ import { BackupExportStream } from './export';
|
|||
import { BackupImportStream } from './import';
|
||||
import { getKeyMaterial } from './crypto';
|
||||
import { BackupCredentials } from './credentials';
|
||||
import { BackupAPI, type DownloadOptionsType } from './api';
|
||||
import { BackupAPI } from './api';
|
||||
import { validateBackup } from './validator';
|
||||
import { BackupType } from './types';
|
||||
import type { ExplodePromiseResultType } from '../../util/explodePromise';
|
||||
|
@ -49,6 +50,20 @@ const IV_LENGTH = 16;
|
|||
|
||||
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
|
||||
|
||||
export type DownloadOptionsType = Readonly<{
|
||||
onProgress?: (
|
||||
backupStep: InstallScreenBackupStep,
|
||||
currentBytes: number,
|
||||
totalBytes: number
|
||||
) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type ImportOptionsType = Readonly<{
|
||||
backupType?: BackupType;
|
||||
onProgress?: (currentBytes: number, totalBytes: number) => void;
|
||||
}>;
|
||||
|
||||
export class BackupsService {
|
||||
private isStarted = false;
|
||||
private isRunning = false;
|
||||
|
@ -82,9 +97,7 @@ export class BackupsService {
|
|||
});
|
||||
}
|
||||
|
||||
public async download(
|
||||
options: Omit<DownloadOptionsType, 'downloadOffset'>
|
||||
): Promise<void> {
|
||||
public async download(options: DownloadOptionsType): Promise<void> {
|
||||
const backupDownloadPath = window.storage.get('backupDownloadPath');
|
||||
if (!backupDownloadPath) {
|
||||
log.warn('backups.download: no backup download path, skipping');
|
||||
|
@ -102,7 +115,10 @@ export class BackupsService {
|
|||
// eslint-disable-next-line no-await-in-loop
|
||||
hasBackup = await this.doDownload(absoluteDownloadPath, options);
|
||||
} catch (error) {
|
||||
log.info('backups.download: error, prompting user to retry');
|
||||
log.warn(
|
||||
'backups.download: error, prompting user to retry',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
this.downloadRetryPromise = explodePromise<RetryBackupImportValue>();
|
||||
window.reduxActions.installer.updateBackupImportProgress({
|
||||
hasError: true,
|
||||
|
@ -202,8 +218,11 @@ export class BackupsService {
|
|||
});
|
||||
}
|
||||
|
||||
public async importFromDisk(backupFile: string): Promise<void> {
|
||||
return backupsService.importBackup(() => createReadStream(backupFile));
|
||||
public async importFromDisk(
|
||||
backupFile: string,
|
||||
options?: ImportOptionsType
|
||||
): Promise<void> {
|
||||
return this.importBackup(() => createReadStream(backupFile), options);
|
||||
}
|
||||
|
||||
public cancelDownload(): void {
|
||||
|
@ -221,7 +240,7 @@ export class BackupsService {
|
|||
|
||||
public async importBackup(
|
||||
createBackupStream: () => Readable,
|
||||
backupType = BackupType.Ciphertext
|
||||
{ backupType = BackupType.Ciphertext, onProgress }: ImportOptionsType = {}
|
||||
): Promise<void> {
|
||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||
|
||||
|
@ -236,8 +255,12 @@ export class BackupsService {
|
|||
// First pass - don't decrypt, only verify mac
|
||||
let hmac = createHmac(HashType.size256, macKey);
|
||||
let theirMac: Uint8Array | undefined;
|
||||
let totalBytes = 0;
|
||||
|
||||
const sink = new PassThrough();
|
||||
sink.on('data', chunk => {
|
||||
totalBytes += chunk.byteLength;
|
||||
});
|
||||
// Discard the data in the first pass
|
||||
sink.resume();
|
||||
|
||||
|
@ -249,6 +272,8 @@ export class BackupsService {
|
|||
sink
|
||||
);
|
||||
|
||||
onProgress?.(0, totalBytes);
|
||||
|
||||
strictAssert(theirMac != null, 'importBackup: Missing MAC');
|
||||
strictAssert(
|
||||
constantTimeEqual(hmac.digest(), theirMac),
|
||||
|
@ -258,9 +283,19 @@ export class BackupsService {
|
|||
// Second pass - decrypt (but still check the mac at the end)
|
||||
hmac = createHmac(HashType.size256, macKey);
|
||||
|
||||
const progressReporter = new PassThrough();
|
||||
progressReporter.pause();
|
||||
|
||||
let currentBytes = 0;
|
||||
progressReporter.on('data', chunk => {
|
||||
currentBytes += chunk.byteLength;
|
||||
onProgress?.(currentBytes, totalBytes);
|
||||
});
|
||||
|
||||
await pipeline(
|
||||
createBackupStream(),
|
||||
getMacAndUpdateHmac(hmac, noop),
|
||||
progressReporter,
|
||||
getIvAndDecipher(aesKey),
|
||||
createGunzip(),
|
||||
new DelimitedStream(),
|
||||
|
@ -343,7 +378,7 @@ export class BackupsService {
|
|||
|
||||
private async doDownload(
|
||||
downloadPath: string,
|
||||
{ onProgress }: Omit<DownloadOptionsType, 'downloadOffset'>
|
||||
{ onProgress }: Pick<DownloadOptionsType, 'onProgress'>
|
||||
): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
@ -371,7 +406,13 @@ export class BackupsService {
|
|||
|
||||
const stream = await this.api.download({
|
||||
downloadOffset,
|
||||
onProgress,
|
||||
onProgress: (currentBytes, totalBytes) => {
|
||||
onProgress?.(
|
||||
InstallScreenBackupStep.Download,
|
||||
currentBytes,
|
||||
totalBytes
|
||||
);
|
||||
},
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
|
@ -395,7 +436,15 @@ export class BackupsService {
|
|||
|
||||
// Too late to cancel now
|
||||
try {
|
||||
await this.importFromDisk(downloadPath);
|
||||
await this.importFromDisk(downloadPath, {
|
||||
onProgress: (currentBytes, totalBytes) => {
|
||||
onProgress?.(
|
||||
InstallScreenBackupStep.Process,
|
||||
currentBytes,
|
||||
totalBytes
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await unlink(downloadPath);
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import pTimeout, { TimeoutError } from 'p-timeout';
|
|||
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
import {
|
||||
InstallScreenBackupStep,
|
||||
InstallScreenStep,
|
||||
InstallScreenError,
|
||||
InstallScreenQRCodeError,
|
||||
|
@ -62,6 +63,7 @@ export type InstallerStateType = ReadonlyDeep<
|
|||
}
|
||||
| {
|
||||
step: InstallScreenStep.BackupImport;
|
||||
backupStep: InstallScreenBackupStep;
|
||||
currentBytes?: number;
|
||||
totalBytes?: number;
|
||||
hasError?: boolean;
|
||||
|
@ -124,6 +126,7 @@ type UpdateBackupImportProgressActionType = ReadonlyDeep<{
|
|||
type: typeof UPDATE_BACKUP_IMPORT_PROGRESS;
|
||||
payload:
|
||||
| {
|
||||
backupStep: InstallScreenBackupStep;
|
||||
currentBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
@ -560,6 +563,7 @@ export function reducer(
|
|||
|
||||
return {
|
||||
step: InstallScreenStep.BackupImport,
|
||||
backupStep: InstallScreenBackupStep.Download,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -581,6 +585,7 @@ export function reducer(
|
|||
|
||||
return {
|
||||
...state,
|
||||
backupStep: action.payload.backupStep,
|
||||
currentBytes: action.payload.currentBytes,
|
||||
totalBytes: action.payload.totalBytes,
|
||||
};
|
||||
|
|
|
@ -109,6 +109,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
|||
step: InstallScreenStep.BackupImport,
|
||||
screenSpecificProps: {
|
||||
i18n,
|
||||
backupStep: installerState.backupStep,
|
||||
currentBytes: installerState.currentBytes,
|
||||
totalBytes: installerState.totalBytes,
|
||||
hasError: installerState.hasError,
|
||||
|
|
|
@ -72,10 +72,9 @@ describe('backup/integration', () => {
|
|||
it(basename(fullPath), async () => {
|
||||
const expectedBuffer = await readFile(fullPath);
|
||||
|
||||
await backupsService.importBackup(
|
||||
() => Readable.from([expectedBuffer]),
|
||||
BackupType.TestOnlyPlaintext
|
||||
);
|
||||
await backupsService.importBackup(() => Readable.from([expectedBuffer]), {
|
||||
backupType: BackupType.TestOnlyPlaintext,
|
||||
});
|
||||
|
||||
const exported = await backupsService.exportBackupData(
|
||||
BackupLevel.Media,
|
||||
|
|
|
@ -109,7 +109,6 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
|
|||
keys: Bytes.fromBase64(LOCAL_ENCRYPTION_KEYS),
|
||||
needIncrementalMac: false,
|
||||
sink: createWriteStream(absolutePath),
|
||||
getAbsoluteAttachmentPath,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -38,8 +38,6 @@ describe('utils/ensureAttachmentIsReencryptable', async () => {
|
|||
plaintext: {
|
||||
absolutePath: plaintextFilePath,
|
||||
},
|
||||
getAbsoluteAttachmentPath:
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||
needIncrementalMac: false,
|
||||
});
|
||||
digest = encrypted.digest;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import createDebug from 'debug';
|
||||
import Long from 'long';
|
||||
import { Proto, StorageState } from '@signalapp/mock-server';
|
||||
|
@ -8,10 +10,16 @@ import { expect } from 'playwright/test';
|
|||
|
||||
import { generateStoryDistributionId } from '../../types/StoryDistributionId';
|
||||
import { MY_STORY_ID } from '../../types/Stories';
|
||||
import { IMAGE_JPEG } from '../../types/MIME';
|
||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||
import * as durations from '../../util/durations';
|
||||
import type { App } from '../playwright';
|
||||
import { Bootstrap } from '../bootstrap';
|
||||
import {
|
||||
getMessageInTimelineByTimestamp,
|
||||
sendTextMessage,
|
||||
sendReaction,
|
||||
} from '../helpers';
|
||||
|
||||
export const debug = createDebug('mock:test:backups');
|
||||
|
||||
|
@ -19,6 +27,15 @@ const IdentifierType = Proto.ManifestRecord.Identifier.Type;
|
|||
|
||||
const DISTRIBUTION1 = generateStoryDistributionId();
|
||||
|
||||
const CAT_PATH = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'fixtures',
|
||||
'cat-screenshot.png'
|
||||
);
|
||||
|
||||
describe('backups', function (this: Mocha.Suite) {
|
||||
this.timeout(100 * durations.MINUTE);
|
||||
|
||||
|
@ -102,9 +119,10 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
const [friend, pinned] = contacts;
|
||||
|
||||
{
|
||||
debug('wait for storage service sync to finish');
|
||||
const window = await app.getWindow();
|
||||
|
||||
debug('wait for storage service sync to finish');
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
const contact = leftPane.locator(
|
||||
`[data-testid="${pinned.device.aci}"] >> "${pinned.profileName}"`
|
||||
|
@ -137,55 +155,85 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
await backButton.last().click();
|
||||
}
|
||||
|
||||
const sends = new Array<Promise<void>>();
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await server.send(
|
||||
desktop,
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await phone.encryptSyncSent(desktop, `to pinned ${i}`, {
|
||||
sends.push(
|
||||
sendTextMessage({
|
||||
from: phone,
|
||||
to: pinned,
|
||||
text: `to pinned ${i}`,
|
||||
desktop,
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
destinationServiceId: pinned.device.aci,
|
||||
})
|
||||
);
|
||||
|
||||
const theirTimestamp = bootstrap.getTimestamp();
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await friend.sendText(desktop, `msg ${i}`, {
|
||||
timestamp: theirTimestamp,
|
||||
});
|
||||
sends.push(
|
||||
sendTextMessage({
|
||||
from: friend,
|
||||
to: desktop,
|
||||
text: `msg ${i}`,
|
||||
desktop,
|
||||
timestamp: theirTimestamp,
|
||||
})
|
||||
);
|
||||
|
||||
const ourTimestamp = bootstrap.getTimestamp();
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await server.send(
|
||||
desktop,
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await phone.encryptSyncSent(desktop, `respond ${i}`, {
|
||||
sends.push(
|
||||
sendTextMessage({
|
||||
from: phone,
|
||||
to: friend,
|
||||
text: `respond ${i}`,
|
||||
desktop,
|
||||
timestamp: ourTimestamp,
|
||||
destinationServiceId: friend.device.aci,
|
||||
})
|
||||
);
|
||||
|
||||
const reactionTimestamp = bootstrap.getTimestamp();
|
||||
sends.push(
|
||||
sendReaction({
|
||||
from: friend,
|
||||
to: desktop,
|
||||
targetAuthor: desktop,
|
||||
targetMessageTimestamp: ourTimestamp,
|
||||
reactionTimestamp,
|
||||
desktop,
|
||||
emoji: '👍',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await friend.sendRaw(
|
||||
const catTimestamp = bootstrap.getTimestamp();
|
||||
const plaintextCat = await readFile(CAT_PATH);
|
||||
const ciphertextCat = await bootstrap.storeAttachmentOnCDN(
|
||||
plaintextCat,
|
||||
IMAGE_JPEG
|
||||
);
|
||||
sends.push(
|
||||
pinned.sendRaw(
|
||||
desktop,
|
||||
{
|
||||
dataMessage: {
|
||||
timestamp: Long.fromNumber(reactionTimestamp),
|
||||
reaction: {
|
||||
emoji: '👍',
|
||||
targetAuthorAci: desktop.aci,
|
||||
targetTimestamp: Long.fromNumber(ourTimestamp),
|
||||
},
|
||||
timestamp: Long.fromNumber(catTimestamp),
|
||||
attachments: [ciphertextCat],
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: reactionTimestamp,
|
||||
timestamp: catTimestamp,
|
||||
}
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(sends);
|
||||
|
||||
{
|
||||
const window = await app.getWindow();
|
||||
await getMessageInTimelineByTimestamp(window, catTimestamp)
|
||||
.locator('img')
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
await app.uploadBackup();
|
||||
|
@ -195,7 +243,7 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
async (window, snapshot) => {
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
const pinnedElem = leftPane.locator(
|
||||
`[data-testid="${pinned.toContact().aci}"] >> "to pinned 4"`
|
||||
`[data-testid="${pinned.toContact().aci}"] >> "Photo"`
|
||||
);
|
||||
|
||||
debug('Waiting for messages to pinned contact to come through');
|
||||
|
@ -246,6 +294,7 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
|
||||
// Restart
|
||||
await bootstrap.eraseStorage();
|
||||
await server.removeAllCDNAttachments();
|
||||
app = await bootstrap.link();
|
||||
await app.waitForBackupImportComplete();
|
||||
|
||||
|
@ -253,6 +302,14 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
// app won't show contacts as "system"
|
||||
await app.waitForContactSync();
|
||||
|
||||
debug('Waiting for attachments to be downloaded');
|
||||
{
|
||||
const window = await app.getWindow();
|
||||
await window
|
||||
.locator('.BackupMediaDownloadProgress__button-close')
|
||||
.click();
|
||||
}
|
||||
|
||||
await comparator(app);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,14 +6,16 @@ import fs from 'fs/promises';
|
|||
import crypto from 'crypto';
|
||||
import path, { join } from 'path';
|
||||
import os from 'os';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import createDebug from 'debug';
|
||||
import pTimeout from 'p-timeout';
|
||||
import normalizePath from 'normalize-path';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
import type { Page } from 'playwright';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import type { Device, PrimaryDevice } from '@signalapp/mock-server';
|
||||
import type { Device, PrimaryDevice, Proto } from '@signalapp/mock-server';
|
||||
import {
|
||||
Server,
|
||||
ServiceIdKind,
|
||||
|
@ -23,9 +25,14 @@ import { MAX_READ_KEYS as MAX_STORAGE_READ_KEYS } from '../services/storageConst
|
|||
import * as durations from '../util/durations';
|
||||
import { drop } from '../util/drop';
|
||||
import type { RendererConfigType } from '../types/RendererConfig';
|
||||
import type { MIMEType } from '../types/MIME';
|
||||
import { App } from './playwright';
|
||||
import { CONTACT_COUNT } from './benchmarks/fixtures';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import {
|
||||
encryptAttachmentV2,
|
||||
generateAttachmentKeys,
|
||||
} from '../AttachmentCrypto';
|
||||
|
||||
export { App };
|
||||
|
||||
|
@ -540,6 +547,38 @@ export class Bootstrap {
|
|||
return join(this.storagePath, 'attachments.noindex', relativePath);
|
||||
}
|
||||
|
||||
public async storeAttachmentOnCDN(
|
||||
data: Buffer,
|
||||
contentType: MIMEType
|
||||
): Promise<Proto.IAttachmentPointer> {
|
||||
const cdnKey = uuid();
|
||||
const keys = generateAttachmentKeys();
|
||||
const cdnNumber = 3;
|
||||
|
||||
const passthrough = new PassThrough();
|
||||
|
||||
const [{ digest }] = await Promise.all([
|
||||
encryptAttachmentV2({
|
||||
keys,
|
||||
plaintext: {
|
||||
data,
|
||||
},
|
||||
needIncrementalMac: false,
|
||||
sink: passthrough,
|
||||
}),
|
||||
this.server.storeAttachmentOnCdn(cdnNumber, cdnKey, passthrough),
|
||||
]);
|
||||
|
||||
return {
|
||||
size: data.byteLength,
|
||||
contentType,
|
||||
cdnKey,
|
||||
cdnNumber,
|
||||
key: keys,
|
||||
digest,
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Getters
|
||||
//
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import createDebug from 'debug';
|
||||
import { assert } from 'chai';
|
||||
import { expect } from 'playwright/test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { type PrimaryDevice, StorageState } from '@signalapp/mock-server';
|
||||
import * as path from 'path';
|
||||
import type { App } from '../playwright';
|
||||
|
@ -17,10 +17,6 @@ import {
|
|||
} from '../helpers';
|
||||
import * as durations from '../../util/durations';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import {
|
||||
encryptAttachmentV2ToDisk,
|
||||
generateAttachmentKeys,
|
||||
} from '../../AttachmentCrypto';
|
||||
import { toBase64 } from '../../Bytes';
|
||||
import type { AttachmentWithNewReencryptionInfoType } from '../../types/Attachment';
|
||||
import { IMAGE_JPEG } from '../../types/MIME';
|
||||
|
@ -144,34 +140,13 @@ describe('attachments', function (this: Mocha.Suite) {
|
|||
|
||||
await page.getByTestId(pinned.device.aci).click();
|
||||
|
||||
const plaintextCat = readFileSync(CAT_PATH);
|
||||
|
||||
const cdnKey = 'cdnKey';
|
||||
const keys = generateAttachmentKeys();
|
||||
const cdnNumber = 3;
|
||||
|
||||
const { digest: newDigest, path: ciphertextPath } =
|
||||
await encryptAttachmentV2ToDisk({
|
||||
keys,
|
||||
plaintext: {
|
||||
// add non-zero byte to the end of the data; this will be considered padding
|
||||
// when received since we will include the size of the un-appended data when
|
||||
// sending
|
||||
data: Buffer.concat([plaintextCat, Buffer.from([1])]),
|
||||
},
|
||||
getAbsoluteAttachmentPath: relativePath =>
|
||||
bootstrap.getAbsoluteAttachmentPath(relativePath),
|
||||
needIncrementalMac: false,
|
||||
});
|
||||
|
||||
const ciphertextCatWithNonZeroPadding = readFileSync(
|
||||
bootstrap.getAbsoluteAttachmentPath(ciphertextPath)
|
||||
);
|
||||
|
||||
await bootstrap.server.storeAttachmentOnCdn(
|
||||
cdnNumber,
|
||||
cdnKey,
|
||||
ciphertextCatWithNonZeroPadding
|
||||
const plaintextCat = await readFile(CAT_PATH);
|
||||
const attachment = await bootstrap.storeAttachmentOnCDN(
|
||||
// add non-zero byte to the end of the data; this will be considered padding
|
||||
// when received since we will include the size of the un-appended data when
|
||||
// sending
|
||||
Buffer.concat([plaintextCat, Buffer.from([1])]),
|
||||
IMAGE_JPEG
|
||||
);
|
||||
|
||||
const incomingTimestamp = Date.now();
|
||||
|
@ -182,12 +157,8 @@ describe('attachments', function (this: Mocha.Suite) {
|
|||
text: 'Wait, that is MY cat! But now with weird padding!',
|
||||
attachments: [
|
||||
{
|
||||
...attachment,
|
||||
size: plaintextCat.byteLength,
|
||||
contentType: IMAGE_JPEG,
|
||||
cdnKey,
|
||||
cdnNumber,
|
||||
key: keys,
|
||||
digest: newDigest,
|
||||
},
|
||||
],
|
||||
timestamp: incomingTimestamp,
|
||||
|
@ -209,8 +180,14 @@ describe('attachments', function (this: Mocha.Suite) {
|
|||
assert.exists(incomingAttachment?.reencryptionInfo);
|
||||
assert.exists(incomingAttachment?.reencryptionInfo.digest);
|
||||
|
||||
assert.strictEqual(incomingAttachment?.key, toBase64(keys));
|
||||
assert.strictEqual(incomingAttachment?.digest, toBase64(newDigest));
|
||||
assert.strictEqual(
|
||||
incomingAttachment?.key,
|
||||
toBase64(attachment.key ?? new Uint8Array(0))
|
||||
);
|
||||
assert.strictEqual(
|
||||
incomingAttachment?.digest,
|
||||
toBase64(attachment.digest ?? new Uint8Array(0))
|
||||
);
|
||||
assert.notEqual(
|
||||
incomingAttachment?.digest,
|
||||
incomingAttachment.reencryptionInfo.digest
|
||||
|
|
|
@ -12,6 +12,11 @@ export enum InstallScreenStep {
|
|||
BackupImport = 'BackupImport',
|
||||
}
|
||||
|
||||
export enum InstallScreenBackupStep {
|
||||
Download = 'Download',
|
||||
Process = 'Process',
|
||||
}
|
||||
|
||||
export enum InstallScreenError {
|
||||
TooManyDevices = 'TooManyDevices',
|
||||
TooOld = 'TooOld',
|
||||
|
|
|
@ -95,8 +95,6 @@ export async function attemptToReencryptToOriginalDigest(
|
|||
),
|
||||
},
|
||||
needIncrementalMac: false,
|
||||
getAbsoluteAttachmentPath:
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||
});
|
||||
} else {
|
||||
strictAssert(attachment.size != null, 'Size must exist');
|
||||
|
@ -127,8 +125,6 @@ export async function attemptToReencryptToOriginalDigest(
|
|||
digestToMatch: fromBase64(digest),
|
||||
},
|
||||
needIncrementalMac: false,
|
||||
getAbsoluteAttachmentPath:
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
@ -150,8 +146,6 @@ export async function generateNewEncryptionInfoForAttachment(
|
|||
),
|
||||
},
|
||||
needIncrementalMac: false,
|
||||
getAbsoluteAttachmentPath:
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||
});
|
||||
} else {
|
||||
const passthrough = new PassThrough();
|
||||
|
@ -177,8 +171,6 @@ export async function generateNewEncryptionInfoForAttachment(
|
|||
size: attachment.size,
|
||||
},
|
||||
needIncrementalMac: false,
|
||||
getAbsoluteAttachmentPath:
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||
}),
|
||||
]);
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
|
|
Loading…
Reference in a new issue