Improve security by isolating code execution more
This commit enhances application security against potential attacks by isolating dependencies that access the host system (like file operations) from the renderer process. It narrows the exposed functionality to script execution only, adding an extra security layer. The changes allow secure and scalable API exposure, preparing for future functionalities such as desktop notifications for script errors (#264), improved script execution handling (#296), and creating restore points (#50) in a secure and repeatable way. Changes include: - Inject `CodeRunner` into Vue components via dependency injection. - Move `CodeRunner` to the application layer as an abstraction for better domain-driven design alignment. - Refactor `SystemOperations` and related interfaces, removing the `I` prefix. - Update architecture documentation for clarity. - Update return types in `NodeSystemOperations` to match the Node APIs. - Improve `WindowVariablesProvider` integration tests for better error context. - Centralize type checks with common functions like `isArray` and `isNumber`. - Change `CodeRunner` to use `os` parameter, ensuring correct window variable injection. - Streamline API exposure to the renderer process: - Automatically bind function contexts to prevent loss of original context. - Implement a way to create facades (wrapper/proxy objects) for increased security.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { isString } from '@/TypeHelpers';
|
||||
|
||||
export function indentText(
|
||||
text: string,
|
||||
indentLevel = 1,
|
||||
@@ -21,7 +23,7 @@ export function filterEmpty(texts: readonly (string | undefined | null)[]): stri
|
||||
}
|
||||
|
||||
function validateText(text: string): void {
|
||||
if (typeof text !== 'string') {
|
||||
if (!isString(text)) {
|
||||
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe } from 'vitest';
|
||||
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
|
||||
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
||||
import { isBoolean } from '@/TypeHelpers';
|
||||
|
||||
describe('SanityChecks', () => {
|
||||
describe('validateRuntimeSanity', () => {
|
||||
@@ -42,7 +43,7 @@ function generateBooleanPermutations<T>(object: T | undefined): T[] {
|
||||
const currentKey = keys[0];
|
||||
const currentValue = object[currentKey];
|
||||
|
||||
if (typeof currentValue !== 'boolean') {
|
||||
if (!isBoolean(currentValue)) {
|
||||
return generateBooleanPermutations({
|
||||
...object,
|
||||
[currentKey]: currentValue,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import { connectApisWithContextBridge } from '@/presentation/electron/preload/ContextBridging/ApiContextBridge';
|
||||
|
||||
describe('ApiContextBridge', () => {
|
||||
describe('connectApisWithContextBridge', () => {
|
||||
it('can provide keys and values', () => {
|
||||
// arrange
|
||||
const bridgeConnector = () => {};
|
||||
// act
|
||||
const act = () => connectApisWithContextBridge(bridgeConnector);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import { provideWindowVariables } from '@/presentation/electron/preload/ContextBridging/RendererApiProvider';
|
||||
import {
|
||||
isArray, isBoolean, isFunction, isNullOrUndefined, isNumber, isPlainObject, isString,
|
||||
} from '@/TypeHelpers';
|
||||
|
||||
describe('RendererApiProvider', () => {
|
||||
describe('provideWindowVariables', () => {
|
||||
describe('conforms to Electron\'s context bridging requirements', () => {
|
||||
// https://www.electronjs.org/docs/latest/api/context-bridge
|
||||
const variables = provideWindowVariables();
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
it(`\`${key}\` conforms to allowed types for context bridging`, () => {
|
||||
// act
|
||||
const act = () => checkAllowedType(value);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function checkAllowedType(value: unknown): void {
|
||||
if (isBasicType(value)) {
|
||||
return;
|
||||
}
|
||||
if (isArray(value)) {
|
||||
checkArrayElements(value);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error(`Type error: Expected a valid object, array, or primitive type, but received type '${typeof value}'.`);
|
||||
}
|
||||
if (isNullOrUndefined(value)) {
|
||||
throw new Error('Type error: Value is null or undefined, which is not allowed.');
|
||||
}
|
||||
checkObjectProperties(value);
|
||||
}
|
||||
|
||||
function isBasicType(value: unknown): boolean {
|
||||
return isString(value) || isNumber(value) || isBoolean(value) || isFunction(value);
|
||||
}
|
||||
|
||||
function checkArrayElements(array: unknown[]): void {
|
||||
array.forEach((item, index) => {
|
||||
try {
|
||||
checkAllowedType(item);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid array element at index ${index}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkObjectProperties(obj: NonNullable<object>): void {
|
||||
if (Object.keys(obj).some((key) => !isString(key))) {
|
||||
throw new Error('Type error: At least one object key is not a string, which violates the allowed types.');
|
||||
}
|
||||
Object.entries(obj).forEach(([key, memberValue]) => {
|
||||
try {
|
||||
checkAllowedType(memberValue);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid object property '${key}': ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider';
|
||||
|
||||
describe('WindowVariablesProvider', () => {
|
||||
describe('provideWindowVariables', () => {
|
||||
describe('conforms to Electron\'s context bridging requirements', () => {
|
||||
// https://www.electronjs.org/docs/latest/api/context-bridge
|
||||
const variables = provideWindowVariables();
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
it(`\`${key}\` conforms to allowed types for context bridging`, () => {
|
||||
expect(checkAllowedType(value)).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function checkAllowedType(value: unknown) {
|
||||
const type = typeof value;
|
||||
if (['string', 'number', 'boolean', 'function'].includes(type)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(checkAllowedType);
|
||||
}
|
||||
if (type === 'object' && value !== null && value !== undefined) {
|
||||
return (
|
||||
// Every key should be a string
|
||||
Object.keys(value).every((key) => typeof key === 'string')
|
||||
// Every value should be of allowed type
|
||||
&& Object.values(value).every(checkAllowedType)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -91,8 +91,8 @@ describe('CodeBuilder', () => {
|
||||
it('appendFunction', () => {
|
||||
// arrange
|
||||
const sut = new CodeBuilderConcrete();
|
||||
const functionName = 'function';
|
||||
const code = 'code';
|
||||
const functionName = 'expected-function-name';
|
||||
const code = 'expected-code';
|
||||
// act
|
||||
sut.appendFunction(functionName, code);
|
||||
// assert
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { FileSystemOps, SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||
import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub';
|
||||
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
|
||||
import { IFileSystemOps, ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { FunctionKeys } from '@/TypeHelpers';
|
||||
|
||||
describe('CodeRunner', () => {
|
||||
describe('TemporaryFileCodeRunner', () => {
|
||||
describe('runCode', () => {
|
||||
it('creates temporary directory recursively', async () => {
|
||||
// arrange
|
||||
@@ -121,11 +120,11 @@ describe('CodeRunner', () => {
|
||||
describe('executes as expected', () => {
|
||||
// arrange
|
||||
const filePath = 'expected-file-path';
|
||||
interface IExecutionTestCase {
|
||||
interface ExecutionTestCase {
|
||||
readonly givenOs: OperatingSystem;
|
||||
readonly expectedCommand: string;
|
||||
}
|
||||
const testData: readonly IExecutionTestCase[] = [
|
||||
const testData: readonly ExecutionTestCase[] = [
|
||||
{
|
||||
givenOs: OperatingSystem.Windows,
|
||||
expectedCommand: filePath,
|
||||
@@ -164,7 +163,7 @@ describe('CodeRunner', () => {
|
||||
}
|
||||
});
|
||||
it('runs in expected order', async () => { // verifies correct `async`, `await` usage.
|
||||
const expectedOrder: readonly FunctionKeys<IFileSystemOps>[] = [
|
||||
const expectedOrder: readonly FunctionKeys<FileSystemOps>[] = [
|
||||
'createDirectory',
|
||||
'writeToFile',
|
||||
'setFilePermissions',
|
||||
@@ -186,7 +185,7 @@ describe('CodeRunner', () => {
|
||||
describe('throws with invalid OS', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly invalidOs: OperatingSystem | undefined;
|
||||
readonly invalidOs: OperatingSystem;
|
||||
readonly expectedError: string;
|
||||
}> = [
|
||||
(() => {
|
||||
@@ -197,11 +196,6 @@ describe('CodeRunner', () => {
|
||||
expectedError: `unsupported os: ${OperatingSystem[unsupportedOs]}`,
|
||||
};
|
||||
})(),
|
||||
{
|
||||
description: 'unknown OS',
|
||||
invalidOs: undefined,
|
||||
expectedError: 'Unidentified operating system',
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({ description, invalidOs, expectedError }) => {
|
||||
it(description, async () => {
|
||||
@@ -225,19 +219,17 @@ class TestContext {
|
||||
|
||||
private fileExtension = 'fileExtension';
|
||||
|
||||
private os: OperatingSystem | undefined = OperatingSystem.Windows;
|
||||
private os: OperatingSystem = OperatingSystem.Windows;
|
||||
|
||||
private systemOperations: ISystemOperations = new SystemOperationsStub();
|
||||
private systemOperations: SystemOperations = new SystemOperationsStub();
|
||||
|
||||
public async runCode(): Promise<void> {
|
||||
const environment = new RuntimeEnvironmentStub()
|
||||
.withOs(this.os);
|
||||
const runner = new CodeRunner(this.systemOperations, environment);
|
||||
await runner.runCode(this.code, this.folderName, this.fileExtension);
|
||||
const runner = new TemporaryFileCodeRunner(this.systemOperations);
|
||||
await runner.runCode(this.code, this.folderName, this.fileExtension, this.os);
|
||||
}
|
||||
|
||||
public withSystemOperations(
|
||||
systemOperations: ISystemOperations,
|
||||
systemOperations: SystemOperations,
|
||||
): this {
|
||||
this.systemOperations = systemOperations;
|
||||
return this;
|
||||
@@ -250,22 +242,22 @@ class TestContext {
|
||||
return this.withSystemOperations(stub);
|
||||
}
|
||||
|
||||
public withOs(os: OperatingSystem | undefined) {
|
||||
public withOs(os: OperatingSystem): this {
|
||||
this.os = os;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFolderName(folderName: string) {
|
||||
public withFolderName(folderName: string): this {
|
||||
this.folderName = folderName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCode(code: string) {
|
||||
public withCode(code: string): this {
|
||||
this.code = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withExtension(fileExtension: string) {
|
||||
public withExtension(fileExtension: string): this {
|
||||
this.fileExtension = fileExtension;
|
||||
return this;
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator';
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { getAbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { getAbsentObjectTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub';
|
||||
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
|
||||
|
||||
describe('WindowVariablesValidator', () => {
|
||||
describe('validateWindowVariables', () => {
|
||||
@@ -92,51 +92,35 @@ describe('WindowVariablesValidator', () => {
|
||||
});
|
||||
|
||||
describe('`isDesktop` property', () => {
|
||||
it('throws an error when only isDesktop is provided and it is true without a system object', () => {
|
||||
it('does not throw when true with valid services', () => {
|
||||
// arrange
|
||||
const systemObject = undefined;
|
||||
const expectedError = getExpectedError(
|
||||
{
|
||||
name: 'system',
|
||||
object: systemObject,
|
||||
},
|
||||
);
|
||||
const validCodeRunner = new CodeRunnerStub();
|
||||
const input = new WindowVariablesStub()
|
||||
.withIsDesktop(true)
|
||||
.withSystem(systemObject);
|
||||
// act
|
||||
const act = () => validateWindowVariables(input);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
|
||||
it('does not throw when isDesktop is true with a valid system object', () => {
|
||||
// arrange
|
||||
const validSystem = new SystemOperationsStub();
|
||||
const input = new WindowVariablesStub()
|
||||
.withIsDesktop(true)
|
||||
.withSystem(validSystem);
|
||||
.withCodeRunner(validCodeRunner);
|
||||
// act
|
||||
const act = () => validateWindowVariables(input);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
|
||||
it('does not throw when isDesktop is false without a system object', () => {
|
||||
// arrange
|
||||
const absentSystem = undefined;
|
||||
const input = new WindowVariablesStub()
|
||||
.withIsDesktop(false)
|
||||
.withSystem(absentSystem);
|
||||
// act
|
||||
const act = () => validateWindowVariables(input);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
describe('does not throw when false without services', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const absentCodeRunner = absentValue;
|
||||
const input = new WindowVariablesStub()
|
||||
.withIsDesktop(false)
|
||||
.withCodeRunner(absentCodeRunner);
|
||||
// act
|
||||
const act = () => validateWindowVariables(input);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
}, { excludeNull: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('`system` property', () => {
|
||||
expectObjectOnDesktop('system');
|
||||
describe('`codeRunner` property', () => {
|
||||
expectObjectOnDesktop('codeRunner');
|
||||
});
|
||||
|
||||
describe('`log` property', () => {
|
||||
@@ -158,6 +142,7 @@ function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
|
||||
describe('validates object type on desktop', () => {
|
||||
itEachInvalidObjectValue((invalidObjectValue) => {
|
||||
// arrange
|
||||
const isOnDesktop = true;
|
||||
const invalidObject = invalidObjectValue as T;
|
||||
const expectedError = getExpectedError({
|
||||
name: key,
|
||||
@@ -165,7 +150,7 @@ function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
|
||||
});
|
||||
const input: WindowVariables = {
|
||||
...new WindowVariablesStub(),
|
||||
isDesktop: true,
|
||||
isDesktop: isOnDesktop,
|
||||
[key]: invalidObject,
|
||||
};
|
||||
// act
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getWindowInjectedSystemOperations } from '@/infrastructure/SystemOperations/WindowInjectedSystemOperations';
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
|
||||
describe('WindowInjectedSystemOperations', () => {
|
||||
describe('getWindowInjectedSystemOperations', () => {
|
||||
describe('throws if window is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing window';
|
||||
const window: WindowVariables = absentValue as never;
|
||||
// act
|
||||
const act = () => getWindowInjectedSystemOperations(window);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true });
|
||||
});
|
||||
describe('throw if system is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing system';
|
||||
const absentSystem = absentValue;
|
||||
const window: Partial<WindowVariables> = {
|
||||
system: absentSystem as never,
|
||||
};
|
||||
// act
|
||||
const act = () => getWindowInjectedSystemOperations(window);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
it('returns from window', () => {
|
||||
// arrange
|
||||
const expectedValue = new SystemOperationsStub();
|
||||
const window: Partial<WindowVariables> = {
|
||||
system: expectedValue,
|
||||
};
|
||||
// act
|
||||
const actualValue = getWindowInjectedSystemOperations(window);
|
||||
// assert
|
||||
expect(actualValue).to.equal(expectedValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ describe('DependencyProvider', () => {
|
||||
useCurrentCode: createTransientTests(),
|
||||
useUserSelectionState: createTransientTests(),
|
||||
useLogger: createTransientTests(),
|
||||
useCodeRunner: createTransientTests(),
|
||||
};
|
||||
Object.entries(testCases).forEach(([key, runTests]) => {
|
||||
const registeredKey = InjectionKeys[key].key;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner';
|
||||
|
||||
describe('UseCodeRunner', () => {
|
||||
it('returns from the provided window object', () => {
|
||||
// arrange
|
||||
const mockCodeRunner = { run: () => {} };
|
||||
const mockWindow = { codeRunner: mockCodeRunner } as unknown as Window;
|
||||
|
||||
// act
|
||||
const { codeRunner } = useCodeRunner(mockWindow);
|
||||
|
||||
// assert
|
||||
expect(codeRunner).to.equal(mockCodeRunner);
|
||||
});
|
||||
|
||||
it('returns undefined when not defined in the window object', () => {
|
||||
// Arrange
|
||||
const mockWindow = {} as unknown as Window;
|
||||
|
||||
// Act
|
||||
const { codeRunner } = useCodeRunner(mockWindow);
|
||||
|
||||
// Assert
|
||||
expect(codeRunner).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import { BridgeConnector, MethodContextBinder, connectApisWithContextBridge } from '@/presentation/electron/preload/ContextBridging/ApiContextBridge';
|
||||
|
||||
describe('ApiContextBridge', () => {
|
||||
describe('connectApisWithContextBridge', () => {
|
||||
it('connects properties as keys', () => {
|
||||
// arrange
|
||||
const context = new BridgeConnectorTestContext();
|
||||
const { exposedItems, bridgeConnector } = mockBridgeConnector();
|
||||
const expectedKeys = ['a', 'b'];
|
||||
const api = {
|
||||
[`${expectedKeys[0]}`]: () => {},
|
||||
[`${expectedKeys[1]}`]: () => {},
|
||||
};
|
||||
|
||||
// act
|
||||
context
|
||||
.withApiObject(api)
|
||||
.withBridgeConnector(bridgeConnector)
|
||||
.run();
|
||||
|
||||
// assert
|
||||
expect(exposedItems).to.have.lengthOf(expectedKeys.length);
|
||||
expect(exposedItems.map(([key]) => key)).to.have.members(expectedKeys);
|
||||
});
|
||||
it('connects values after binding their context', () => {
|
||||
// arrange
|
||||
const context = new BridgeConnectorTestContext();
|
||||
const { exposedItems, bridgeConnector } = mockBridgeConnector();
|
||||
const rawValues = ['a', 'b'];
|
||||
const api = {
|
||||
first: rawValues[0],
|
||||
second: rawValues[1],
|
||||
};
|
||||
const boundValues = {
|
||||
[`${rawValues[0]}`]: 'bound-a',
|
||||
[`${rawValues[1]}`]: 'bound-b',
|
||||
};
|
||||
const expectedValues = Object.values(boundValues);
|
||||
const contextBinderMock: MethodContextBinder = (property) => {
|
||||
return boundValues[property as string] as never;
|
||||
};
|
||||
|
||||
// act
|
||||
context
|
||||
.withApiObject(api)
|
||||
.withContextBinder(contextBinderMock)
|
||||
.withBridgeConnector(bridgeConnector)
|
||||
.run();
|
||||
|
||||
// assert
|
||||
expect(exposedItems).to.have.lengthOf(rawValues.length);
|
||||
expect(exposedItems.map(([,value]) => value)).to.have.members(expectedValues);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockBridgeConnector() {
|
||||
const exposedItems = new Array<[string, unknown]>();
|
||||
const bridgeConnector: BridgeConnector = (key, api) => exposedItems.push([key, api]);
|
||||
return {
|
||||
exposedItems,
|
||||
bridgeConnector,
|
||||
};
|
||||
}
|
||||
|
||||
class BridgeConnectorTestContext {
|
||||
private bridgeConnector: BridgeConnector = () => {};
|
||||
|
||||
private apiObject: object = {};
|
||||
|
||||
private contextBinder: MethodContextBinder = (obj) => obj;
|
||||
|
||||
public withBridgeConnector(bridgeConnector: BridgeConnector): this {
|
||||
this.bridgeConnector = bridgeConnector;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withApiObject(apiObject: object): this {
|
||||
this.apiObject = apiObject;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withContextBinder(contextBinder: MethodContextBinder): this {
|
||||
this.contextBinder = contextBinder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public run() {
|
||||
return connectApisWithContextBridge(
|
||||
this.bridgeConnector,
|
||||
this.apiObject,
|
||||
this.contextBinder,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { bindObjectMethods } from '@/presentation/electron/preload/ContextBridging/MethodContextBinder';
|
||||
|
||||
describe('MethodContextBinder', () => {
|
||||
describe('bindObjectMethods', () => {
|
||||
it('binds methods of an object to itself', () => {
|
||||
// arrange
|
||||
class TestClass {
|
||||
constructor(public value: number) {}
|
||||
|
||||
increment() {
|
||||
this.value += 1;
|
||||
}
|
||||
}
|
||||
const instance = new TestClass(0);
|
||||
|
||||
// act
|
||||
const boundInstance = bindObjectMethods(instance);
|
||||
boundInstance.increment();
|
||||
|
||||
// assert
|
||||
expect(boundInstance.value).toBe(1);
|
||||
});
|
||||
|
||||
it('handles objects without prototype methods gracefully', () => {
|
||||
// arrange
|
||||
const object = Object.create(null); // object without prototype
|
||||
object.value = 0;
|
||||
// eslint-disable-next-line func-names
|
||||
object.increment = function () {
|
||||
this.value += 1;
|
||||
};
|
||||
|
||||
// act
|
||||
const boundObject = bindObjectMethods(object);
|
||||
|
||||
// assert
|
||||
expect(() => boundObject.increment()).not.toThrow();
|
||||
});
|
||||
|
||||
it('recursively binds methods in nested objects', () => {
|
||||
// arrange
|
||||
const nestedObject = {
|
||||
child: {
|
||||
value: 0,
|
||||
increment() {
|
||||
this.value += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// act
|
||||
const boundObject = bindObjectMethods(nestedObject);
|
||||
boundObject.child.increment();
|
||||
|
||||
// assert
|
||||
expect(boundObject.child.value).toBe(1);
|
||||
});
|
||||
|
||||
it('recursively binds methods in arrays', () => {
|
||||
// arrange
|
||||
const array = [
|
||||
{
|
||||
value: 0,
|
||||
increment() {
|
||||
this.value += 1;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// act
|
||||
const boundArray = bindObjectMethods(array);
|
||||
boundArray[0].increment();
|
||||
|
||||
// assert
|
||||
expect(boundArray[0].value).toBe(1);
|
||||
});
|
||||
|
||||
describe('returns the same object if it is neither an object nor an array', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly value: unknown;
|
||||
}> = [
|
||||
{
|
||||
description: 'given primitive',
|
||||
value: 42,
|
||||
},
|
||||
{
|
||||
description: 'null',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
description: 'undefined',
|
||||
value: undefined,
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({ description, value }) => {
|
||||
it(description, () => {
|
||||
// act
|
||||
const boundValue = bindObjectMethods(value);
|
||||
// assert
|
||||
expect(boundValue).toBe(value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('skips binding inherited properties', () => {
|
||||
// arrange
|
||||
class ParentClass {
|
||||
inheritedMethod() {}
|
||||
}
|
||||
class TestClass extends ParentClass {
|
||||
constructor(public value: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.value += 1;
|
||||
}
|
||||
}
|
||||
const instance = new TestClass(0);
|
||||
const originalInheritedMethod = instance.inheritedMethod;
|
||||
|
||||
// act
|
||||
const boundInstance = bindObjectMethods(instance);
|
||||
|
||||
// assert
|
||||
expect(boundInstance.inheritedMethod).toBe(originalInheritedMethod);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { convertPlatformToOs } from '@/presentation/electron/preload/NodeOsMapper';
|
||||
import { convertPlatformToOs } from '@/presentation/electron/preload/ContextBridging/NodeOsMapper';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
|
||||
describe('NodeOsMapper', () => {
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ApiFacadeFactory, provideWindowVariables } from '@/presentation/electron/preload/ContextBridging/RendererApiProvider';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { CodeRunner } from '@/application/CodeRunner';
|
||||
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
|
||||
import { PropertyKeys } from '@/TypeHelpers';
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
|
||||
describe('RendererApiProvider', () => {
|
||||
describe('provideWindowVariables', () => {
|
||||
interface WindowVariableTestCase {
|
||||
readonly description: string;
|
||||
setupContext(context: RendererApiProviderTestContext): RendererApiProviderTestContext;
|
||||
readonly expectedValue: unknown;
|
||||
}
|
||||
const testScenarios: Record<PropertyKeys<Required<WindowVariables>>, WindowVariableTestCase> = {
|
||||
isDesktop: {
|
||||
description: 'returns true',
|
||||
setupContext: (context) => context,
|
||||
expectedValue: true,
|
||||
},
|
||||
codeRunner: (() => {
|
||||
const codeRunner = new CodeRunnerStub();
|
||||
const createFacadeMock: ApiFacadeFactory = (obj) => obj;
|
||||
return {
|
||||
description: 'encapsulates correctly',
|
||||
setupContext: (context) => context
|
||||
.withCodeRunner(codeRunner)
|
||||
.withApiFacadeCreator(createFacadeMock),
|
||||
expectedValue: codeRunner,
|
||||
};
|
||||
})(),
|
||||
os: (() => {
|
||||
const operatingSystem = OperatingSystem.WindowsPhone;
|
||||
return {
|
||||
description: 'returns expected',
|
||||
setupContext: (context) => context.withOs(operatingSystem),
|
||||
expectedValue: operatingSystem,
|
||||
};
|
||||
})(),
|
||||
log: (() => {
|
||||
const logger = new LoggerStub();
|
||||
const createFacadeMock: ApiFacadeFactory = (obj) => obj;
|
||||
return {
|
||||
description: 'encapsulates correctly',
|
||||
setupContext: (context) => context
|
||||
.withLogger(logger)
|
||||
.withApiFacadeCreator(createFacadeMock),
|
||||
expectedValue: logger,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
Object.entries(testScenarios).forEach((
|
||||
[property, { description, setupContext, expectedValue }],
|
||||
) => {
|
||||
it(`${property}: ${description}`, () => {
|
||||
// arrange
|
||||
const testContext = setupContext(new RendererApiProviderTestContext());
|
||||
// act
|
||||
const variables = testContext.provideWindowVariables();
|
||||
// assert
|
||||
const actualValue = variables[property];
|
||||
expect(actualValue).to.equal(expectedValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class RendererApiProviderTestContext {
|
||||
private codeRunner: CodeRunner = new CodeRunnerStub();
|
||||
|
||||
private os: OperatingSystem = OperatingSystem.Android;
|
||||
|
||||
private log: Logger = new LoggerStub();
|
||||
|
||||
private apiFacadeCreator: ApiFacadeFactory = (obj) => obj;
|
||||
|
||||
public withCodeRunner(codeRunner: CodeRunner): this {
|
||||
this.codeRunner = codeRunner;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withOs(os: OperatingSystem): this {
|
||||
this.os = os;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLogger(log: Logger): this {
|
||||
this.log = log;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withApiFacadeCreator(apiFacadeCreator: ApiFacadeFactory): this {
|
||||
this.apiFacadeCreator = apiFacadeCreator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideWindowVariables() {
|
||||
return provideWindowVariables(
|
||||
() => this.codeRunner,
|
||||
() => this.log,
|
||||
() => this.os,
|
||||
this.apiFacadeCreator,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createSecureFacade } from '@/presentation/electron/preload/ContextBridging/SecureFacadeCreator';
|
||||
|
||||
describe('SecureFacadeCreator', () => {
|
||||
describe('createSecureFacade', () => {
|
||||
describe('methods', () => {
|
||||
it('allows access to external methods', () => {
|
||||
// arrange
|
||||
let value = 0;
|
||||
const testObject = {
|
||||
increment: () => value++,
|
||||
};
|
||||
const facade = createSecureFacade(testObject, ['increment']);
|
||||
|
||||
// act
|
||||
facade.increment();
|
||||
|
||||
// assert
|
||||
expect(value).toBe(1);
|
||||
});
|
||||
it('proxies external methods', () => {
|
||||
// arrange
|
||||
const testObject = {
|
||||
method: () => {},
|
||||
};
|
||||
const facade = createSecureFacade(testObject, ['method']);
|
||||
|
||||
// act
|
||||
const actualMethod = facade.method;
|
||||
|
||||
// assert
|
||||
expect(testObject.method).not.equal(actualMethod);
|
||||
expect(testObject.method).not.equal(actualMethod);
|
||||
});
|
||||
it('does not expose internal methods', () => {
|
||||
// arrange
|
||||
interface External {
|
||||
publicMethod(): void;
|
||||
}
|
||||
interface Internal {
|
||||
privateMethod(): void;
|
||||
}
|
||||
const testObject: External & Internal = {
|
||||
publicMethod: () => {},
|
||||
privateMethod: () => {},
|
||||
};
|
||||
const facade = createSecureFacade<External>(testObject, ['publicMethod']);
|
||||
|
||||
// act
|
||||
facade.publicMethod();
|
||||
|
||||
// assert
|
||||
expect((facade as unknown as Internal).privateMethod).toBeUndefined();
|
||||
});
|
||||
it('maintains original function context', () => {
|
||||
// arrange
|
||||
const testObject = {
|
||||
value: 0,
|
||||
increment() { this.value++; },
|
||||
};
|
||||
// act
|
||||
const facade = createSecureFacade(testObject, ['increment', 'value']);
|
||||
// assert
|
||||
facade.increment();
|
||||
expect(testObject.value).toBe(1);
|
||||
});
|
||||
});
|
||||
describe('properties', () => {
|
||||
it('allows access to external properties', () => {
|
||||
// arrange
|
||||
const testObject = { a: 1 };
|
||||
// act
|
||||
const facade = createSecureFacade(testObject, ['a']);
|
||||
// assert
|
||||
expect(facade.a).toBe(1);
|
||||
});
|
||||
it('does not expose internal properties', () => {
|
||||
// arrange
|
||||
interface External {
|
||||
readonly public: string;
|
||||
}
|
||||
interface Internal {
|
||||
readonly private: string;
|
||||
}
|
||||
const testObject: External & Internal = {
|
||||
public: '',
|
||||
private: '',
|
||||
};
|
||||
const facade = createSecureFacade<External>(testObject, ['public']);
|
||||
|
||||
// act
|
||||
(() => facade.public)();
|
||||
|
||||
// assert
|
||||
expect((facade as unknown as Internal).private).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
|
||||
describe('WindowVariablesProvider', () => {
|
||||
describe('provideWindowVariables', () => {
|
||||
it('returns expected `system`', () => {
|
||||
// arrange
|
||||
const expectedValue = new SystemOperationsStub();
|
||||
// act
|
||||
const variables = new TestContext()
|
||||
.withSystem(expectedValue)
|
||||
.provideWindowVariables();
|
||||
// assert
|
||||
expect(variables.system).to.equal(expectedValue);
|
||||
});
|
||||
it('returns expected `os`', () => {
|
||||
// arrange
|
||||
const expectedValue = OperatingSystem.WindowsPhone;
|
||||
// act
|
||||
const variables = new TestContext()
|
||||
.withOs(expectedValue)
|
||||
.provideWindowVariables();
|
||||
// assert
|
||||
expect(variables.os).to.equal(expectedValue);
|
||||
});
|
||||
it('returns expected `log`', () => {
|
||||
// arrange
|
||||
const expectedValue = new LoggerStub();
|
||||
// act
|
||||
const variables = new TestContext()
|
||||
.withLogger(expectedValue)
|
||||
.provideWindowVariables();
|
||||
// assert
|
||||
expect(variables.log).to.equal(expectedValue);
|
||||
});
|
||||
it('`isDesktop` is true', () => {
|
||||
// arrange
|
||||
const expectedValue = true;
|
||||
// act
|
||||
const variables = new TestContext()
|
||||
.provideWindowVariables();
|
||||
// assert
|
||||
expect(variables.isDesktop).to.equal(expectedValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestContext {
|
||||
private system: ISystemOperations = new SystemOperationsStub();
|
||||
|
||||
private os: OperatingSystem = OperatingSystem.Android;
|
||||
|
||||
private log: Logger = new LoggerStub();
|
||||
|
||||
public withSystem(system: ISystemOperations): this {
|
||||
this.system = system;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withOs(os: OperatingSystem): this {
|
||||
this.os = os;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLogger(log: Logger): this {
|
||||
this.log = log;
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideWindowVariables() {
|
||||
return provideWindowVariables(
|
||||
() => this.system,
|
||||
() => this.log,
|
||||
() => this.os,
|
||||
);
|
||||
}
|
||||
}
|
||||
7
tests/unit/shared/Stubs/CodeRunnerStub.ts
Normal file
7
tests/unit/shared/Stubs/CodeRunnerStub.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CodeRunner } from '@/application/CodeRunner';
|
||||
|
||||
export class CodeRunnerStub implements CodeRunner {
|
||||
public runCode(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ICommandOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { CommandOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class CommandOpsStub
|
||||
extends StubWithObservableMethodCalls<ICommandOps>
|
||||
implements ICommandOps {
|
||||
public execute(command: string): void {
|
||||
extends StubWithObservableMethodCalls<CommandOps>
|
||||
implements CommandOps {
|
||||
public execute(command: string): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'execute',
|
||||
args: [command],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IFileSystemOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { FileSystemOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class FileSystemOpsStub
|
||||
extends StubWithObservableMethodCalls<IFileSystemOps>
|
||||
implements IFileSystemOps {
|
||||
extends StubWithObservableMethodCalls<FileSystemOps>
|
||||
implements FileSystemOps {
|
||||
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'setFilePermissions',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ILocationOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { LocationOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class LocationOpsStub
|
||||
extends StubWithObservableMethodCalls<ILocationOps>
|
||||
implements ILocationOps {
|
||||
extends StubWithObservableMethodCalls<LocationOps>
|
||||
implements LocationOps {
|
||||
private sequence = new Array<string>();
|
||||
|
||||
private scenarios = new Map<string, string>();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IOperatingSystemOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { OperatingSystemOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class OperatingSystemOpsStub
|
||||
extends StubWithObservableMethodCalls<IOperatingSystemOps>
|
||||
implements IOperatingSystemOps {
|
||||
extends StubWithObservableMethodCalls<OperatingSystemOps>
|
||||
implements OperatingSystemOps {
|
||||
private temporaryDirectory = '/stub-temp-dir/';
|
||||
|
||||
public withTemporaryDirectoryResult(directory: string): this {
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import {
|
||||
ICommandOps,
|
||||
IFileSystemOps,
|
||||
IOperatingSystemOps,
|
||||
ILocationOps,
|
||||
ISystemOperations,
|
||||
} from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import type {
|
||||
CommandOps,
|
||||
FileSystemOps,
|
||||
OperatingSystemOps,
|
||||
LocationOps,
|
||||
SystemOperations,
|
||||
} from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||
import { CommandOpsStub } from './CommandOpsStub';
|
||||
import { FileSystemOpsStub } from './FileSystemOpsStub';
|
||||
import { LocationOpsStub } from './LocationOpsStub';
|
||||
import { OperatingSystemOpsStub } from './OperatingSystemOpsStub';
|
||||
|
||||
export class SystemOperationsStub implements ISystemOperations {
|
||||
public operatingSystem: IOperatingSystemOps = new OperatingSystemOpsStub();
|
||||
export class SystemOperationsStub implements SystemOperations {
|
||||
public operatingSystem: OperatingSystemOps = new OperatingSystemOpsStub();
|
||||
|
||||
public location: ILocationOps = new LocationOpsStub();
|
||||
public location: LocationOps = new LocationOpsStub();
|
||||
|
||||
public fileSystem: IFileSystemOps = new FileSystemOpsStub();
|
||||
public fileSystem: FileSystemOps = new FileSystemOpsStub();
|
||||
|
||||
public command: ICommandOps = new CommandOpsStub();
|
||||
public command: CommandOps = new CommandOpsStub();
|
||||
|
||||
public withOperatingSystem(operatingSystemOps: IOperatingSystemOps): this {
|
||||
public withOperatingSystem(operatingSystemOps: OperatingSystemOps): this {
|
||||
this.operatingSystem = operatingSystemOps;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLocation(location: ILocationOps): this {
|
||||
public withLocation(location: LocationOps): this {
|
||||
this.location = location;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileSystem(fileSystem: IFileSystemOps): this {
|
||||
public withFileSystem(fileSystem: FileSystemOps): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCommand(command: ICommandOps): this {
|
||||
public withCommand(command: CommandOps): this {
|
||||
this.command = command;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
import { SystemOperationsStub } from './SystemOperationsStub';
|
||||
import { CodeRunner } from '@/application/CodeRunner';
|
||||
import { LoggerStub } from './LoggerStub';
|
||||
import { CodeRunnerStub } from './CodeRunnerStub';
|
||||
|
||||
export class WindowVariablesStub implements WindowVariables {
|
||||
public system?: ISystemOperations = new SystemOperationsStub();
|
||||
public codeRunner?: CodeRunner = new CodeRunnerStub();
|
||||
|
||||
public isDesktop = false;
|
||||
|
||||
@@ -29,8 +29,8 @@ export class WindowVariablesStub implements WindowVariables {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSystem(value?: ISystemOperations): this {
|
||||
this.system = value;
|
||||
public withCodeRunner(value?: CodeRunner): this {
|
||||
this.codeRunner = value;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user