Files
privacy.sexy/tests/unit/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog.spec.ts
undergroundwires f03fc24098 Add AD detection on desktop app #264, #304
This commit addresses issues #264 and #304, where users were not
receiving error messages when script execution failed due to
antivirus intervention, particularly with Microsoft Defender.
Now, desktop app users will see a detailed error message with
guidance on next steps if script saving or execution fails due
to antivirus removal.

Key changes:

- Implement a check to detect failure in file writing,
  including reading the written file back. This method effectively
  detects antivirus interventions, as the read operation triggers
  an antivirus scan, leading to file deletion by the antivirus.
- Introduce a specific error message for scenarios where an
  antivirus intervention is detected.
2024-01-16 22:26:28 +01:00

438 lines
15 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { FileType, SaveFileErrorType } from '@/presentation/common/Dialog';
import { ElectronFileDialogOperations, NodeElectronSaveFileDialog, NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
import { FileReadbackVerificationErrors, FileWriteOperationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub';
import { NodePathOperationsStub } from './NodePathOperationsStub';
describe('NodeElectronSaveFileDialog', () => {
describe('dialog options', () => {
it('correct title', async () => {
// arrange
const expectedFileName = 'expected-file-name';
const electronMock = new ElectronFileDialogOperationsStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withDefaultFilename(expectedFileName);
// act
await context.saveFile();
// assert
assertDialogOptionMatchesExpectedValue(expectedFileName, (opts) => opts.title, electronMock);
});
it('correct properties', async () => {
// arrange
const expectedProperties = [
'createDirectory',
'showOverwriteConfirmation',
];
const electronMock = new ElectronFileDialogOperationsStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock);
// act
await context.saveFile();
// assert
assertDialogOptionMatchesExpectedValue(
expectedProperties,
(opts) => opts.properties,
electronMock,
);
});
it('correct default path', async () => {
// arrange
const pathSegmentSeparator = '_{test-separator}_';
const expectedFileName = 'expected-file-name';
const expectedParentDirectory = 'expected-downloads-path';
const expectedFilePath = [
expectedParentDirectory,
expectedFileName,
].join(pathSegmentSeparator);
const electronMock = new ElectronFileDialogOperationsStub()
.withUserDownloadsPath(expectedParentDirectory);
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withDefaultFilename(expectedFileName)
.withNode(new NodePathOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
// act
await context.saveFile();
// assert
assertDialogOptionMatchesExpectedValue(
expectedFilePath,
(opts) => opts.defaultPath,
electronMock,
);
});
describe('correct file type filters', () => {
const defaultFilter: Electron.FileFilter = {
name: 'All Files',
extensions: ['*'],
};
const testScenarios: Record<FileType, Electron.FileFilter[]> = {
[FileType.BatchFile]: [
defaultFilter,
{
name: 'Batch Files',
extensions: ['bat', 'cmd'],
},
],
[FileType.ShellScript]: [
defaultFilter,
{
name: 'Shell Scripts',
extensions: ['sh', 'bash', 'zsh'],
},
],
};
Object.entries(testScenarios).forEach(([fileTypeKey, expectedFilters]) => {
const fileType = Number(fileTypeKey) as FileType;
it(`applies correct filters for ${FileType[fileType]}`, async () => {
// arrange
const electronMock = new ElectronFileDialogOperationsStub();
const context = new SaveFileDialogTestSetup()
.withFileType(fileType)
.withElectron(electronMock);
// act
await context.saveFile();
// assert
const sortFilters = (
filters: Electron.FileFilter[],
) => filters.sort((a, b) => a.name.localeCompare(b.name));
const expectedSortedFilters = sortFilters(expectedFilters);
assertDialogOptionMatchesExpectedValue(
expectedSortedFilters,
(opts) => sortFilters(opts.filters ?? []),
electronMock,
);
});
});
});
});
describe('file saving process', () => {
describe('when dialog is confirmed', () => {
it('writes to the selected file path', async () => {
// arrange
const expectedFilePath = 'expected-file-path';
const isCancelled = false;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled)
.withUserSelectedFilePath(expectedFilePath);
const fileWriterStub = new ReadbackFileWriterStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withFileWriter(fileWriterStub);
// act
await context.saveFile();
// assert
const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
expect(saveFileCalls).to.have.lengthOf(1);
const [actualFilePath] = saveFileCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('writes the correct file contents', async () => {
// arrange
const expectedFileContents = 'expected-file-contents';
const isCancelled = false;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled);
const fileWriterStub = new ReadbackFileWriterStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withFileContents(expectedFileContents)
.withFileWriter(fileWriterStub);
// act
await context.saveFile();
// assert
const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
expect(saveFileCalls).to.have.lengthOf(1);
const [,actualFileContents] = saveFileCalls[0].args;
expect(actualFileContents).to.equal(expectedFileContents);
});
it('returns success status', async () => {
// arrange
const expectedSuccessValue = true;
const context = new SaveFileDialogTestSetup();
// act
const { success } = await context.saveFile();
// assert
expect(success).to.equal(expectedSuccessValue);
});
});
describe('when dialog is canceled', async () => {
it('does not save file', async () => {
// arrange
const isCancelled = true;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled);
const fileWriterStub = new ReadbackFileWriterStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withFileWriter(fileWriterStub);
// act
await context.saveFile();
// assert
const saveFileCall = fileWriterStub.callHistory.find((c) => c.methodName === 'writeAndVerifyFile');
expect(saveFileCall).to.equal(undefined);
});
it('logs cancelation info', async () => {
// arrange
const expectedLogMessagePart = 'File save cancelled';
const logger = new LoggerStub();
const isCancelled = true;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled);
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withLogger(logger);
// act
await context.saveFile();
// assert
logger.assertLogsContainMessagePart('info', expectedLogMessagePart);
});
it('returns success', async () => {
// arrange
const expectedSuccessValue = true;
const isCancelled = true;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled);
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock);
// act
const { success } = await context.saveFile();
// assert
expect(success).to.equal(expectedSuccessValue);
});
});
});
describe('error handling', () => {
interface SaveFileFailureTestScenario {
readonly description: string;
readonly expectedErrorType: SaveFileErrorType;
readonly expectedErrorMessage: string;
readonly expectLogs: boolean,
buildFaultyContext(
setup: SaveFileDialogTestSetup,
errorMessage: string,
): SaveFileDialogTestSetup;
}
const testScenarios: ReadonlyArray<SaveFileFailureTestScenario> = [
...FileWriteOperationErrors.map((writeError): SaveFileFailureTestScenario => ({
description: `file writing failure: ${writeError}`,
expectedErrorType: 'FileCreationError',
expectedErrorMessage: 'Error when writing to file',
expectLogs: false,
buildFaultyContext: (setup, errorMessage) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
const fileWriterStub = new ReadbackFileWriterStub();
fileWriterStub.configureFailure(writeError, errorMessage);
return setup
.withElectron(electronMock)
.withFileWriter(fileWriterStub);
},
})),
...FileReadbackVerificationErrors.map((verificationError): SaveFileFailureTestScenario => ({
description: `file verification failure: ${verificationError}`,
expectedErrorType: 'FileReadbackVerificationError',
expectedErrorMessage: 'Error when verifying the file',
expectLogs: false,
buildFaultyContext: (setup, errorMessage) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
const fileWriterStub = new ReadbackFileWriterStub();
fileWriterStub.configureFailure(verificationError, errorMessage);
return setup
.withElectron(electronMock)
.withFileWriter(fileWriterStub);
},
})),
{
description: 'user path retrieval failure',
expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Error when retrieving user path',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
electronMock.getUserDownloadsPath = () => {
throw new Error(errorMessage);
};
return setup
.withElectron(electronMock);
},
},
{
description: 'path combination failure',
expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Error when combining paths',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => {
const nodeMock = new NodePathOperationsStub();
nodeMock.join = () => {
throw new Error(errorMessage);
};
return setup
.withNode(nodeMock);
},
},
{
description: 'dialog display failure',
expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Error when showing save dialog',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
electronMock.showSaveDialog = () => Promise.reject(new Error(errorMessage));
return setup
.withElectron(electronMock);
},
},
{
description: 'unexpected dialog return value failure',
expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Unexpected Error: File path is undefined after save dialog completion.',
expectLogs: true,
buildFaultyContext: (setup) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
electronMock.showSaveDialog = () => Promise.resolve({
canceled: false,
filePath: undefined,
});
return setup
.withElectron(electronMock);
},
},
];
testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs,
}) => {
it(`handles error - ${description}`, async () => {
// arrange
const context = buildFaultyContext(
new SaveFileDialogTestSetup(),
expectedErrorMessage,
);
// act
const { success, error } = await context.saveFile();
// assert
expect(success).to.equal(false);
expectExists(error);
expect(error.message).to.include(expectedErrorMessage);
expect(error.type).to.equal(expectedErrorType);
});
if (expectLogs) {
it(`logs error - ${description}`, async () => {
// arrange
const loggerStub = new LoggerStub();
const context = buildFaultyContext(
new SaveFileDialogTestSetup()
.withLogger(loggerStub),
expectedErrorMessage,
);
// act
await context.saveFile();
// assert
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
});
}
});
});
});
class SaveFileDialogTestSetup {
private fileContents = `${SaveFileDialogTestSetup.name} file contents`;
private filename = `${SaveFileDialogTestSetup.name} filename`;
private fileType = FileType.BatchFile;
private logger: Logger = new LoggerStub();
private electron: ElectronFileDialogOperations = new ElectronFileDialogOperationsStub();
private node: NodeFileOperations = new NodePathOperationsStub();
private fileWriter: ReadbackFileWriter = new ReadbackFileWriterStub();
public withElectron(electron: ElectronFileDialogOperations): this {
this.electron = electron;
return this;
}
public withNode(node: NodeFileOperations): this {
this.node = node;
return this;
}
public withFileWriter(fileWriter: ReadbackFileWriter): this {
this.fileWriter = fileWriter;
return this;
}
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
public withDefaultFilename(defaultFilename: string): this {
this.filename = defaultFilename;
return this;
}
public withFileContents(fileContents: string): this {
this.fileContents = fileContents;
return this;
}
public withFileType(fileType: FileType): this {
this.fileType = fileType;
return this;
}
public saveFile() {
const dialog = new NodeElectronSaveFileDialog(
this.logger,
this.electron,
this.node,
this.fileWriter,
);
return dialog.saveFile(
this.fileContents,
this.filename,
this.fileType,
);
}
}
function assertDialogOptionMatchesExpectedValue<T>(
expectedValue: T,
getActualOption: (opts: Electron.SaveDialogOptions) => T | undefined,
electronMock: ElectronFileDialogOperationsStub,
): void {
const showDialogCalls = electronMock.callHistory.filter((c) => c.methodName === 'showSaveDialog');
expect(showDialogCalls).to.have.lengthOf(1);
const showDialogCall = showDialogCalls[0];
expectExists(showDialogCall);
const [options] = showDialogCall.args;
expectExists(options);
const actualValue = getActualOption(options);
expect(actualValue).to.deep.equal(expectedValue);
}