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.
This commit is contained in:
undergroundwires
2024-01-16 22:26:28 +01:00
parent 756c736e21
commit f03fc24098
23 changed files with 841 additions and 142 deletions

View File

@@ -0,0 +1,34 @@
import { FileReadWriteOperations } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
export class FileReadWriteOperationsStub
extends StubWithObservableMethodCalls<FileReadWriteOperations>
implements FileReadWriteOperations {
private readonly writtenFiles: Record<string, string> = {};
public writeFile = (filePath: string, fileContents: string, encoding: NodeJS.BufferEncoding) => {
this.registerMethodCall({
methodName: 'writeFile',
args: [filePath, fileContents, encoding],
});
this.writtenFiles[filePath] = fileContents;
return Promise.resolve();
};
public access = (...args: Parameters<FileReadWriteOperations['access']>) => {
this.registerMethodCall({
methodName: 'access',
args: [...args],
});
return Promise.resolve();
};
public readFile = (filePath: string, encoding: NodeJS.BufferEncoding) => {
this.registerMethodCall({
methodName: 'readFile',
args: [filePath, encoding],
});
const fileContents = this.writtenFiles[filePath];
return Promise.resolve(fileContents);
};
}

View File

@@ -0,0 +1,299 @@
import { constants } from 'node:fs';
import { describe, it, expect } from 'vitest';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { FunctionKeys } from '@/TypeHelpers';
import { sequenceEqual } from '@/application/Common/Array';
import { FileWriteErrorType } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { FileReadWriteOperations, NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { FileReadWriteOperationsStub } from './FileReadWriteOperationsStub';
describe('NodeReadbackFileWriter', () => {
describe('writeAndVerifyFile', () => {
describe('successful write and verify operations', () => {
it('confirms successful operation', async () => {
// arrange
const context = new NodeReadbackFileWriterTestSetup();
// act
const { success } = await context.writeAndVerifyFile();
// assert
expect(success).to.equal(true);
});
describe('file write operations', () => {
it('writes to specified path', async () => {
// arrange
const expectedFilePath = 'test.txt';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFilePath(expectedFilePath)
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
expect(fileWriteCalls).to.have.lengthOf(1);
const [actualFilePath] = fileWriteCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('writes specified contents', async () => {
// arrange
const expectedFileContents = 'expected file contents';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub)
.withFileContents(expectedFileContents);
// act
await context.writeAndVerifyFile();
// assert
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
expect(fileWriteCalls).to.have.lengthOf(1);
const [,actualFileContents] = fileWriteCalls[0].args;
expect(actualFileContents).to.equal(expectedFileContents);
});
it('uses correct encoding', async () => {
// arrange
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
expect(fileWriteCalls).to.have.lengthOf(1);
const [,,actualEncoding] = fileWriteCalls[0].args;
expect(actualEncoding).to.equal(expectedEncoding);
});
});
describe('existence verification', () => {
it('checks correct path', async () => {
// arrange
const expectedFilePath = 'test-file-path';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub)
.withFilePath(expectedFilePath);
// act
await context.writeAndVerifyFile();
// assert
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
expect(accessCalls).to.have.lengthOf(1);
const [actualFilePath] = accessCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('uses correct mode', async () => {
// arrange
const expectedMode = constants.F_OK;
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
expect(accessCalls).to.have.lengthOf(1);
const [,actualMode] = accessCalls[0].args;
expect(actualMode).to.equal(expectedMode);
});
});
describe('content verification', () => {
it('reads from correct path', async () => {
// arrange
const expectedFilePath = 'expected-file-path.txt';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub)
.withFilePath(expectedFilePath);
// act
await context.writeAndVerifyFile();
// assert
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
expect(fileReadCalls).to.have.lengthOf(1);
const [actualFilePath] = fileReadCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('uses correct encoding', async () => {
// arrange
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
expect(fileReadCalls).to.have.lengthOf(1);
const [,actualEncoding] = fileReadCalls[0].args;
expect(actualEncoding).to.equal(expectedEncoding);
});
});
it('executes file system operations in correct sequence', async () => {
// arrange
const expectedOrder: ReadonlyArray<FunctionKeys<FileReadWriteOperations>> = [
'writeFile',
'access',
'readFile',
];
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const actualOrder = fileSystemStub.callHistory.map((c) => c.methodName);
expect(sequenceEqual(expectedOrder, actualOrder)).to.equal(true);
});
});
describe('error handling', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectedErrorType: FileWriteErrorType;
readonly expectedErrorMessage: string;
buildFaultyContext(
setup: NodeReadbackFileWriterTestSetup,
errorMessage: string,
): NodeReadbackFileWriterTestSetup;
}> = [
{
description: 'writing failure',
expectedErrorType: 'WriteOperationFailed',
expectedErrorMessage: 'Error when writing file',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.writeFile = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'existence verification error',
expectedErrorType: 'FileExistenceVerificationFailed',
expectedErrorMessage: 'Access denied',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.access = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'reading failure',
expectedErrorType: 'ReadVerificationFailed',
expectedErrorMessage: 'Read error',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.readFile = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'content match failure',
expectedErrorType: 'ContentVerificationFailed',
expectedErrorMessage: 'The contents of the written file do not match the expected contents.',
buildFaultyContext: (setup) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.readFile = () => Promise.resolve('different contents');
return setup
.withFileSystem(fileSystemStub);
},
},
];
testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
}) => {
it(`handles error - ${description}`, async () => {
// arrange
const context = buildFaultyContext(
new NodeReadbackFileWriterTestSetup(),
expectedErrorMessage,
);
// act
const { success, error } = await context.writeAndVerifyFile();
// assert
expect(success).to.equal(false);
expectExists(error);
expect(error.message).to.include(expectedErrorMessage);
expect(error.type).to.equal(expectedErrorType);
});
it(`logs error - ${description}`, async () => {
// arrange
const loggerStub = new LoggerStub();
const context = buildFaultyContext(
new NodeReadbackFileWriterTestSetup()
.withLogger(loggerStub),
expectedErrorMessage,
);
// act
await context.writeAndVerifyFile();
// assert
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
});
});
});
});
});
class NodeReadbackFileWriterTestSetup {
private logger: Logger = new LoggerStub();
private fileSystem: FileReadWriteOperations = new FileReadWriteOperationsStub();
private filePath = '/test/file/path.txt';
private fileContents = 'test file contents';
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
public withFileSystem(fileSystem: FileReadWriteOperations): this {
this.fileSystem = fileSystem;
return this;
}
public withFilePath(filePath: string): this {
this.filePath = filePath;
return this;
}
public withFileContents(fileContents: string): this {
this.fileContents = fileContents;
return this;
}
public writeAndVerifyFile(): ReturnType<NodeReadbackFileWriter['writeAndVerifyFile']> {
const writer = new NodeReadbackFileWriter(
this.logger,
this.fileSystem,
);
return writer.writeAndVerifyFile(
this.filePath,
this.fileContents,
);
}
}