Encrypt sensitive project settings (#506)

* Split out appSettingsForm from appSettingsPage

* Added tests for app settings page

* Fixed merge conflictd issues

* Added string localizations for app settings form updates

* Started adding security token input component

* Updating security token input component

* Integrate security token into project settings

* Renamed to proected input and reused for all senstive data fields

* Added project encryption/decryption

* Making progress on syncing encrypted/decrypted project data

* Added unit tests to encrypt/decrypt object

* Updated project service unit tests

* Updated encrypted/decrypted data sync

* Updated project action tests

* Cleaned up unit tests

* Fixed editor page unit tests

* Fixing unit tests

* Updated editor page tests

* Refactored connection form tests

* Added unit tests for protected input component

* Added unit tests for security token picker

* Merged unit test changes

* Added unit test to verify new security token is created for new projects

* Addressed PR feedback
This commit is contained in:
Wallace Breza 2019-01-25 10:51:55 -08:00 коммит произвёл GitHub
Родитель 50bd9a3d86
Коммит ed98ef2ce3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
48 изменённых файлов: 947 добавлений и 307 удалений

Просмотреть файл

@ -1,4 +1,4 @@
import { generateKey, encrypt, decrypt } from "./crypto";
import { generateKey, encrypt, decrypt, encryptObject, decryptObject } from "./crypto";
describe("Crypto", () => {
it("generates a key", () => {
@ -65,4 +65,33 @@ describe("Crypto", () => {
expect(() => decrypt("ABC123XYZSDAFASDFS23453", secret)).toThrowError();
});
it("encrypts and decrypts a javascript object", () => {
const secret = generateKey();
const original = {
firstName: "John",
lastName: "Doe",
active: true,
age: 30,
};
const encrypted = encryptObject(original, secret);
const decrypted = decryptObject(encrypted, secret);
expect(original).toEqual(decrypted);
});
it("decrypt object fails with invalid key", () => {
const key1 = generateKey();
const key2 = generateKey();
const original = {
firstName: "John",
lastName: "Doe",
active: true,
age: 30,
};
const encrypted = encryptObject(original, key1);
expect(() => decryptObject(encrypted, key2)).toThrowError();
});
});

Просмотреть файл

@ -34,6 +34,17 @@ export function encrypt(message: string, secret: string): string {
}
}
/**
* Encryptes a javascript object with the specified key
* @param message - The javascript object to encrypt
* @param secret - The secret to encrypt the message
*/
export function encryptObject(message: any, secret: string): string {
Guard.null(message);
return encrypt(JSON.stringify(message), secret);
}
/**
* Decrypts the specified message with the provided key
* @param encodedMessage The base64 encoded encrypted data
@ -59,3 +70,12 @@ export function decrypt(encodedMessage: string, secret: string): string {
throw new Error(`Error decrypting data - ${e.message}`);
}
}
/**
* Decryptes a javascript object with the specified key
* @param message - The encrypted base64 encded message
* @param secret - The secret to decrypt the message
*/
export function decryptObject<T = any>(encodedMessage: string, secret: string): T {
const json = decrypt(encodedMessage, secret);
return JSON.parse(json) as T;
}

Просмотреть файл

@ -61,6 +61,10 @@ export const english: IAppStrings = {
},
projectSettings: {
title: "Project Settings",
securityToken: {
title: "Security Token",
description: "Used to encrypt sensitive data within project files",
},
save: "Save Project",
sourceConnection: {
title: "Source Connection",

Просмотреть файл

@ -62,6 +62,10 @@ export const spanish: IAppStrings = {
},
projectSettings: {
title: "Configuración de Proyecto",
securityToken: {
title: "Token de seguridad",
description: "Se utiliza para cifrar datos confidenciales dentro de archivos de proyecto",
},
save: "Guardar el Proyecto",
sourceConnection: {
title: "Conexión de Origen",

Просмотреть файл

@ -1,8 +1,8 @@
import shortid from "shortid";
import {
AssetState, AssetType, IApplicationState, IAppSettings, IAsset, IAssetMetadata,
IConnection, IExportFormat, IProject, ITag, StorageType, EditorMode,
IAppError, IProjectVideoSettings, AppErrorType,
IConnection, IExportFormat, IProject, ITag, StorageType, ISecurityToken,
IAppError, IProjectVideoSettings, AppErrorType, EditorMode,
} from "../models/applicationState";
import { ExportAssetState } from "../providers/export/exportProvider";
import { IAssetProvider, IAssetProviderRegistrationOptions } from "../providers/storage/assetProviderFactory";
@ -19,6 +19,9 @@ import { IEditorPageProps } from "../react/components/pages/editorPage/editorPag
import {
IAzureCustomVisionTag, IAzureCustomVisionRegion,
} from "../providers/export/azureCustomVision/azureCustomVisionService";
import IApplicationActions, * as applicationActions from "../redux/actions/applicationActions";
import { ILocalFileSystemProxyOptions } from "../providers/storage/localFileSystemProxy";
import { generateKey } from "./crypto";
export default class MockFactory {
@ -135,6 +138,7 @@ export default class MockFactory {
return {
id: `project-${name}`,
name: `Project ${name}`,
securityToken: `Security-Token-${name}`,
assets: {},
exportFormat: MockFactory.exportFormat(),
sourceConnection: connection,
@ -164,6 +168,12 @@ export default class MockFactory {
};
}
public static createLocalFileSystemOptions(): ILocalFileSystemProxyOptions {
return {
folderPath: "C:\\projects\\vott\\project",
};
}
/**
* Creates fake response for Azure Blob Storage `listContainers` function
*/
@ -193,14 +203,14 @@ export default class MockFactory {
* Creates fake data for testing Azure Cloud Storage
*/
public static createAzureData() {
const options = this.createAzureOptions();
const options = MockFactory.createAzureOptions();
return {
blobName: "file1.jpg",
blobText: "This is the content",
fileType: "image/jpg",
containerName: options.containerName,
containers: this.createAzureContainers(),
blobs: this.createAzureBlobs(),
containers: MockFactory.createAzureContainers(),
blobs: MockFactory.createAzureBlobs(),
options,
};
}
@ -275,7 +285,7 @@ export default class MockFactory {
* @param name Name of connection
*/
public static createTestCloudConnection(name: string = "test"): IConnection {
return this.createTestConnection(name, "azureBlobStorage");
return MockFactory.createTestConnection(name, "azureBlobStorage");
}
/**
@ -302,7 +312,7 @@ export default class MockFactory {
name: `Connection ${name}`,
description: `Description for Connection ${name}`,
providerType,
providerOptions: this.getProviderOptions(providerType),
providerOptions: MockFactory.getProviderOptions(providerType),
};
}
@ -323,10 +333,12 @@ export default class MockFactory {
*/
public static getProviderOptions(providerType) {
switch (providerType) {
case "localFileSystemProxy":
return MockFactory.createLocalFileSystemOptions();
case "azureBlobStorage":
return this.createAzureOptions();
return MockFactory.createAzureOptions();
case "bingImageSearch":
return this.createBingOptions();
return MockFactory.createBingOptions();
default:
return {};
}
@ -355,7 +367,7 @@ export default class MockFactory {
deleteFile: jest.fn(),
writeText: jest.fn(),
writeBinary: jest.fn(),
listFiles: jest.fn(() => Promise.resolve(this.createFileList())),
listFiles: jest.fn(() => Promise.resolve(MockFactory.createFileList())),
listContainers: jest.fn(),
createContainer: jest.fn(),
deleteContainer: jest.fn(),
@ -369,8 +381,8 @@ export default class MockFactory {
*/
public static createStorageProviderFromConnection(connection: IConnection): IStorageProvider {
return {
...this.createStorageProvider(),
storageType: this.getStorageType(connection.providerType),
...MockFactory.createStorageProvider(),
storageType: MockFactory.getStorageType(connection.providerType),
};
}
@ -514,7 +526,8 @@ export default class MockFactory {
*/
public static projectService(): IProjectService {
return {
save: jest.fn((project: IProject) => Promise.resolve()),
load: jest.fn((project: IProject) => Promise.resolve(project)),
save: jest.fn((project: IProject) => Promise.resolve(project)),
delete: jest.fn((project: IProject) => Promise.resolve()),
isDuplicate: jest.fn((project: IProject, projectList: IProject[]) => true),
};
@ -551,20 +564,50 @@ export default class MockFactory {
* Creates fake IAppSettings
*/
public static appSettings(): IAppSettings {
const securityTokens = MockFactory.createSecurityTokens();
return {
devToolsEnabled: false,
securityTokens: [],
securityTokens: [
...securityTokens,
MockFactory.createSecurityToken("TestProject"),
],
};
}
/**
* Creates a security token used for testing
* @param nameSuffix The name suffix to apply to the security token name
*/
public static createSecurityToken(nameSuffix: string): ISecurityToken {
return {
name: `Security-Token-${nameSuffix}`,
key: generateKey(),
};
}
/**
* Creates test security tokens
* @param count The number of tokens to generate (default: 10)
*/
public static createSecurityTokens(count: number = 10): ISecurityToken[] {
const securityTokens: ISecurityToken[] = [];
for (let i = 1; i <= 10; i++) {
securityTokens.push(MockFactory.createSecurityToken(i.toString()));
}
return securityTokens;
}
/**
* Creates fake IProjectSettingsPageProps
* @param projectId Current project ID
*/
public static projectSettingsProps(projectId?: string): IProjectSettingsPageProps {
return {
...this.pageProps(projectId, "settings"),
connections: this.createTestConnections(),
...MockFactory.pageProps(projectId, "settings"),
connections: MockFactory.createTestConnections(),
appSettings: MockFactory.appSettings(),
};
}
@ -573,7 +616,10 @@ export default class MockFactory {
* @param projectId Current project ID
*/
public static editorPageProps(projectId?: string): IEditorPageProps {
return this.pageProps(projectId, "edit");
return {
actions: (projectActions as any) as IProjectActions,
...MockFactory.pageProps(projectId, "edit"),
};
}
/**
@ -648,10 +694,11 @@ export default class MockFactory {
return {
project: null,
recentProjects: MockFactory.createTestProjects(),
actions: (projectActions as any) as IProjectActions,
history: this.history(),
location: this.location(),
match: this.match(projectId, method),
projectActions: (projectActions as any) as IProjectActions,
applicationActions: (applicationActions as any) as IApplicationActions,
history: MockFactory.history(),
location: MockFactory.location(),
match: MockFactory.match(projectId, method),
};
}

Просмотреть файл

@ -64,15 +64,19 @@ export interface IAppStrings {
};
projectSettings: {
title: string;
securityToken: {
title: string;
description: string;
},
save: string;
sourceConnection: {
title: string;
description: string;
}
},
targetConnection: {
title: string;
description: string;
}
},
videoSettings: {
title: string;
description: string;

Просмотреть файл

@ -1,4 +1,5 @@
import { randomIntInRange, createQueryString } from "./utils";
import { randomIntInRange, createQueryString, encryptProject, decryptProject } from "./utils";
import MockFactory from "./mockFactory";
describe("Helper functions", () => {
@ -32,4 +33,22 @@ describe("Helper functions", () => {
expect(result)
.toEqual("a=1&b=A%20string%20with%20a%20space&c=A%20string%20with%20a%20%23%20and%20a%20%26%20char&d=true");
});
describe("Encryption Utils", () => {
const testProject = MockFactory.createTestProject("TestProject");
const securityToken = MockFactory.createSecurityToken("TestProject");
it("encrypt project does not double encrypt project", () => {
const encryptedProject = encryptProject(testProject, securityToken);
const doubleEncryptedProject = encryptProject(encryptedProject, securityToken);
expect(encryptedProject).toEqual(doubleEncryptedProject);
});
it("decrypt project does not attempt to decrypt already decrtyped data", () => {
const decryptedProject = decryptProject(testProject, securityToken);
expect(decryptedProject).toEqual(testProject);
});
});
});

Просмотреть файл

@ -1,4 +1,6 @@
import Guard from "./guard";
import { IProject, ISecurityToken, IProviderOptions, ISecureString } from "../models/applicationState";
import { encryptObject, decryptObject } from "./crypto";
/**
* Generates a random integer in provided range
@ -45,3 +47,78 @@ export function createQueryString(object: any): string {
return parts.join("&");
}
/**
* Encrypts sensitive settings for the specified project and returns the result
* @param project The project to encrypt
* @param securityToken The security token used to encrypt the project
*/
export function encryptProject(project: IProject, securityToken: ISecurityToken): IProject {
const encrypted: IProject = {
...project,
sourceConnection: { ...project.sourceConnection },
targetConnection: { ...project.targetConnection },
exportFormat: project.exportFormat ? { ...project.exportFormat } : null,
};
encrypted.sourceConnection.providerOptions =
encryptProviderOptions(project.sourceConnection.providerOptions, securityToken.key);
encrypted.targetConnection.providerOptions =
encryptProviderOptions(project.targetConnection.providerOptions, securityToken.key);
if (encrypted.exportFormat) {
encrypted.exportFormat.providerOptions =
encryptProviderOptions(project.exportFormat.providerOptions, securityToken.key);
}
return encrypted;
}
/**
* Decrypts sensitive settings for the specified project and return the result
* @param project The project to decrypt
* @param securityToken The security token used to decrypt the project
*/
export function decryptProject(project: IProject, securityToken: ISecurityToken): IProject {
const decrypted: IProject = {
...project,
sourceConnection: { ...project.sourceConnection },
targetConnection: { ...project.targetConnection },
exportFormat: project.exportFormat ? { ...project.exportFormat } : null,
};
decrypted.sourceConnection.providerOptions =
decryptProviderOptions(decrypted.sourceConnection.providerOptions, securityToken.key);
decrypted.targetConnection.providerOptions =
decryptProviderOptions(decrypted.targetConnection.providerOptions, securityToken.key);
if (decrypted.exportFormat) {
decrypted.exportFormat.providerOptions =
decryptProviderOptions(decrypted.exportFormat.providerOptions, securityToken.key);
}
return decrypted;
}
function encryptProviderOptions(providerOptions: IProviderOptions | ISecureString, secret: string): ISecureString {
if (!providerOptions) {
return null;
}
if (providerOptions.encrypted) {
return providerOptions as ISecureString;
}
return {
encrypted: encryptObject(providerOptions, secret),
};
}
function decryptProviderOptions<T = IProviderOptions>(providerOptions: IProviderOptions | ISecureString, secret): T {
const secureString = providerOptions as ISecureString;
if (!(secureString && secureString.encrypted)) {
return providerOptions as T;
}
return decryptObject(providerOptions.encrypted, secret) as T;
}

Просмотреть файл

@ -1,3 +1,5 @@
import { ExportAssetState } from "../providers/export/exportProvider";
/**
* @name - Application State
* @description - Defines the root level application state
@ -38,6 +40,14 @@ export enum AppErrorType {
Render = "render",
}
/**
* @name - Provider Options
* @description - Property map of key values used within a export / asset / storage provider
*/
export interface IProviderOptions {
[key: string]: any;
}
/**
* @name - Application settings
* @description - Defines the root level configuration options for the application
@ -54,6 +64,7 @@ export interface IAppSettings {
* @description - Defines the structure of a VoTT project
* @member id - Unique identifier
* @member name - User defined name
* @member securityToken - The Base64 encoded token used to encrypt sensitive project data
* @member description - User defined description
* @member tags - User defined list of tags
* @member sourceConnection - Full source connection details
@ -65,6 +76,7 @@ export interface IAppSettings {
export interface IProject {
id: string;
name: string;
securityToken: string;
description?: string;
tags: ITag[];
sourceConnection: IConnection;
@ -111,7 +123,11 @@ export interface IConnection {
name: string;
description?: string;
providerType: string;
providerOptions: object;
providerOptions: IProviderOptions | ISecureString;
}
export interface IExportProviderOptions extends IProviderOptions {
assetState: ExportAssetState;
}
/**
@ -124,7 +140,7 @@ export interface IConnection {
*/
export interface IExportFormat {
providerType: string;
providerOptions: any;
providerOptions: IExportProviderOptions | ISecureString;
}
/**

Просмотреть файл

@ -4,7 +4,10 @@ import { AzureCustomVisionProvider, IAzureCustomVisionExportOptions, NewOrExisti
import registerProviders from "../../registerProviders";
import { ExportProviderFactory } from "./exportProviderFactory";
import MockFactory from "../../common/mockFactory";
import { IProject, AssetState, IAsset, IAssetMetadata, RegionType, IRegion } from "../../models/applicationState";
import {
IProject, AssetState, IAsset, IAssetMetadata,
RegionType, IRegion, IExportProviderOptions,
} from "../../models/applicationState";
import { ExportAssetState } from "./exportProvider";
jest.mock("./azureCustomVision/azureCustomVisionService");
import {
@ -18,6 +21,20 @@ import HtmlFileReader from "../../common/htmlFileReader";
describe("Azure Custom Vision Export Provider", () => {
let testProject: IProject = null;
const defaultOptions: IAzureCustomVisionExportOptions = {
apiKey: expect.any(String),
assetState: ExportAssetState.All,
newOrExisting: NewOrExisting.New,
projectId: expect.any(String),
};
function createProvider(project: IProject): AzureCustomVisionProvider {
return new AzureCustomVisionProvider(
project,
project.exportFormat.providerOptions as IAzureCustomVisionExportOptions,
);
}
beforeEach(() => {
jest.resetAllMocks();
@ -32,6 +49,7 @@ describe("Azure Custom Vision Export Provider", () => {
exportFormat: {
providerType: "azureCustomVision",
providerOptions: {
assetState: ExportAssetState.All,
projectdId: "azure-custom-vision-project-1",
apiKey: "ABC123",
},
@ -60,30 +78,23 @@ describe("Azure Custom Vision Export Provider", () => {
});
});
const customVisionOptions: IAzureCustomVisionExportOptions = {
apiKey: expect.any(String),
assetState: ExportAssetState.All,
newOrExisting: NewOrExisting.New,
projectId: expect.any(String),
};
testProject.exportFormat.providerOptions = customVisionOptions;
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
testProject.exportFormat.providerOptions = defaultOptions;
const provider = createProvider(testProject);
const newOptions = await provider.save(testProject.exportFormat);
const customVisionProject: IAzureCustomVisionProject = {
name: customVisionOptions.name,
description: customVisionOptions.description,
classificationType: customVisionOptions.classificationType,
domainId: customVisionOptions.domainId,
projectType: customVisionOptions.projectType,
name: defaultOptions.name,
description: defaultOptions.description,
classificationType: defaultOptions.classificationType,
domainId: defaultOptions.domainId,
projectType: defaultOptions.projectType,
};
expect(AzureCustomVisionService.prototype.create).toBeCalledWith(customVisionProject);
expect(newOptions).toEqual(expect.objectContaining({
assetState: customVisionOptions.assetState,
apiKey: customVisionOptions.apiKey,
assetState: defaultOptions.assetState,
apiKey: defaultOptions.apiKey,
projectId: expect.any(String),
newOrExisting: NewOrExisting.Existing,
}));
@ -93,28 +104,20 @@ describe("Azure Custom Vision Export Provider", () => {
const customVisionMock = AzureCustomVisionService as jest.Mocked<typeof AzureCustomVisionService>;
customVisionMock.prototype.create = jest.fn((project) => Promise.reject("Error creating project"));
const customVisionOptions: IAzureCustomVisionExportOptions = {
apiKey: expect.any(String),
assetState: ExportAssetState.All,
newOrExisting: NewOrExisting.New,
projectId: expect.any(String),
};
testProject.exportFormat.providerOptions = defaultOptions;
const provider = createProvider(testProject);
testProject.exportFormat.providerOptions = customVisionOptions;
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
await expect(provider.save(testProject.exportFormat)).rejects.not.toBeNull();
});
it("Calling save with Existing project returns existing provider settings", async () => {
const customVisionOptions: IAzureCustomVisionExportOptions = {
apiKey: expect.any(String),
assetState: ExportAssetState.All,
...defaultOptions,
newOrExisting: NewOrExisting.Existing,
projectId: expect.any(String),
};
testProject.exportFormat.providerOptions = customVisionOptions;
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
const provider = createProvider(testProject);
const newOptions = await provider.save(testProject.exportFormat);
expect(newOptions).toEqual(customVisionOptions);
@ -176,11 +179,10 @@ describe("Azure Custom Vision Export Provider", () => {
});
it("Uploads binaries, regions & tags for all assets", async () => {
testProject.exportFormat.providerOptions.assetState = ExportAssetState.All;
(testProject.exportFormat.providerOptions as IExportProviderOptions).assetState = ExportAssetState.All;
const allAssets = _.values(testProject.assets);
const taggedAssets = _.values(testProject.assets).filter((asset) => asset.state === AssetState.Tagged);
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
const provider = createProvider(testProject);
const results = await provider.export();
expect(results).not.toBeNull();
@ -191,15 +193,15 @@ describe("Azure Custom Vision Export Provider", () => {
});
it("Uploads binaries, regions & tags for visited assets", async () => {
testProject.exportFormat.providerOptions.assetState = ExportAssetState.Visited;
(testProject.exportFormat.providerOptions as IExportProviderOptions).assetState = ExportAssetState.Visited;
const visitedAssets = _
.values(testProject.assets)
.filter((asset) => asset.state === AssetState.Visited || asset.state === AssetState.Tagged);
const taggedAssets = _
.values(testProject.assets)
.filter((asset) => asset.state === AssetState.Tagged);
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
const provider = createProvider(testProject);
const results = await provider.export();
expect(results).not.toBeNull();
@ -210,10 +212,9 @@ describe("Azure Custom Vision Export Provider", () => {
});
it("Uploads binaries, regions & tags for tagged assets", async () => {
testProject.exportFormat.providerOptions.assetState = ExportAssetState.Tagged;
(testProject.exportFormat.providerOptions as IExportProviderOptions).assetState = ExportAssetState.Tagged;
const taggedAssets = _.values(testProject.assets).filter((asset) => asset.state === AssetState.Tagged);
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
const provider = createProvider(testProject);
const results = await provider.export();
expect(results).not.toBeNull();
@ -234,19 +235,19 @@ describe("Azure Custom Vision Export Provider", () => {
return Promise.resolve(existingTags);
});
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
const provider = createProvider(testProject);
await provider.export();
expect(AzureCustomVisionService.prototype.createTag)
.toBeCalledTimes(testProject.tags.length - existingTags.length);
});
it("Returns export results", async () => {
testProject.exportFormat.providerOptions.assetState = ExportAssetState.All;
(testProject.exportFormat.providerOptions as IExportProviderOptions).assetState = ExportAssetState.All;
const allAssets = _.values(testProject.assets);
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
const provider = createProvider(testProject);
const results = await provider.export();
expect(results.count).toEqual(allAssets.length);
expect(results.completed.length).toEqual(allAssets.length);
expect(results.errors.length).toEqual(0);
@ -263,11 +264,12 @@ describe("Azure Custom Vision Export Provider", () => {
}
});
testProject.exportFormat.providerOptions.assetState = ExportAssetState.All;
const allAssets = _.values(testProject.assets);
const provider = new AzureCustomVisionProvider(testProject, testProject.exportFormat.providerOptions);
(testProject.exportFormat.providerOptions as IExportProviderOptions).assetState = ExportAssetState.All;
const allAssets = _.values(testProject.assets);
const provider = createProvider(testProject);
const results = await provider.export();
expect(results.count).toEqual(allAssets.length);
expect(results.completed.length).toEqual(allAssets.length - 1);
expect(results.errors.length).toEqual(1);

Просмотреть файл

@ -5,7 +5,7 @@ import Guard from "../../common/guard";
import { AssetService } from "../../services/assetService";
import {
IProject, IExportFormat, IAsset, AssetState, IAssetMetadata,
IBoundingBox, ISize,
IBoundingBox, ISize, IProviderOptions,
} from "../../models/applicationState";
import {
AzureCustomVisionService, IAzureCustomVisionServiceOptions, IAzureCustomVisionProject,
@ -16,7 +16,7 @@ import HtmlFileReader from "../../common/htmlFileReader";
/**
* Options for Azure Custom Vision Service
*/
export interface IAzureCustomVisionExportOptions {
export interface IAzureCustomVisionExportOptions extends IProviderOptions {
assetState: ExportAssetState;
newOrExisting: NewOrExisting;
apiKey: string;

Просмотреть файл

@ -1,4 +1,7 @@
{
"apiKey": {
"ui:widget": "protectedInput"
},
"description": {
"ui:widget": "textarea"
},

Просмотреть файл

@ -1,23 +1,10 @@
import { ExportProviderFactory } from "./exportProviderFactory";
import { ExportProvider } from "./exportProvider";
import { IProject } from "../../models/applicationState";
import MockFactory from "../../common/mockFactory";
describe("Export Provider Factory", () => {
const testProject: IProject = {
id: "1",
name: "Test Project",
autoSave: true,
exportFormat: {
providerType: "TestExportProvider",
providerOptions: {},
},
videoSettings: {
frameExtractionRate: 15,
},
sourceConnection: null,
tags: [],
targetConnection: null,
};
const testProject: IProject = MockFactory.createTestProject("TestProject");
it("registers new export providers", () => {
expect(Object.keys(ExportProviderFactory.providers).length).toEqual(0);

Просмотреть файл

@ -24,7 +24,9 @@ describe("VoTT Json Export Provider", () => {
},
exportFormat: {
providerType: "json",
providerOptions: {},
providerOptions: {
assetState: ExportAssetState.All,
},
},
};

Просмотреть файл

@ -1 +1,5 @@
{}
{
"sas": {
"ui:widget": "protectedInput"
}
}

Просмотреть файл

@ -1 +1,5 @@
{}
{
"apiKey": {
"ui:widget": "protectedInput"
}
}

Просмотреть файл

@ -15,13 +15,6 @@ describe("Alert component", () => {
return mount(<Alert {...props}></Alert>);
}
afterEach(() => {
const node = document.body;
while (node.firstChild) {
node.removeChild(node.firstChild);
}
});
it("Is defined", () => {
expect(Alert).toBeDefined();
});

Просмотреть файл

@ -16,13 +16,6 @@ describe("Confirm component", () => {
return mount(<Confirm {...props}></Confirm>);
}
afterEach(() => {
const node = document.body;
while (node.firstChild) {
node.removeChild(node.firstChild);
}
});
it("Is defined", () => {
expect(Confirm).toBeDefined();
});

Просмотреть файл

@ -7,7 +7,7 @@ import Guard from "../../../../common/guard";
* @param Widget UI Widget for form
* @param mapProps Function mapping props to an object
*/
export default function CustomField(Widget: any, mapProps?: (props: FieldProps) => any) {
export default function CustomField<Props = {}>(Widget: any, mapProps?: (props: FieldProps) => Props) {
Guard.null(Widget);
return function render(props: FieldProps) {

Просмотреть файл

@ -46,7 +46,7 @@ export default class LocalFolderPicker extends React.Component<ILocalFolderPicke
return (
<div className="input-group">
<input id={id} type="text" className="form-control" value={value} readOnly />
<input id={id} type="text" className="form-control" value={value} readOnly={true} />
<div className="input-group-append">
<button className="btn btn-primary"
type="button"

Просмотреть файл

@ -24,13 +24,6 @@ describe("MessageBox component", () => {
</MessageBox>);
}
afterEach(() => {
const node = document.body;
while (node.firstChild) {
node.removeChild(node.firstChild);
}
});
it("Is defined", () => {
expect(MessageBox).toBeDefined();
});

Просмотреть файл

@ -0,0 +1,98 @@
import React from "react";
import { mount, ReactWrapper } from "enzyme";
import { ProtectedInput, IProtectedInputProps, IProtectedInputState } from "./protectedInput";
import { generateKey } from "../../../../common/crypto";
describe("Protected Input Component", () => {
const onChangeHandler = jest.fn();
const defaultProps: IProtectedInputProps = {
id: "protected-input",
value: "",
onChange: onChangeHandler,
};
beforeAll(() => {
const clipboard = (navigator as any).clipboard;
if (!(clipboard && clipboard.writeText)) {
(navigator as any).clipboard = {
writeText: jest.fn(() => Promise.resolve()),
};
}
});
function createComponent(props: IProtectedInputProps): ReactWrapper<IProtectedInputProps, IProtectedInputState> {
return mount(<ProtectedInput {...props} />);
}
it("renders correctly", () => {
const wrapper = createComponent(defaultProps);
expect(wrapper.find("input").exists()).toBe(true);
expect(wrapper.find("input").prop("type")).toEqual("password");
expect(wrapper.find("input").prop("readOnly")).not.toBeDefined();
expect(wrapper.find(".btn-visibility").exists()).toBe(true);
expect(wrapper.find(".btn-copy").exists()).toBe(true);
});
it("renders as read-only if property is set", () => {
const props = {
...defaultProps,
readOnly: true,
};
const wrapper = createComponent(props);
expect(wrapper.find("input").prop("readOnly")).toEqual(true);
});
it("sets default state", () => {
const wrapper = createComponent(defaultProps);
expect(wrapper.state()).toEqual({
showKey: false,
value: "",
});
});
it("calls onChange event handler with bound value on load", () => {
const expectedValue = generateKey();
const props = {
...defaultProps,
value: expectedValue,
};
createComponent(props);
expect(onChangeHandler).toBeCalledWith(expectedValue);
});
it("calls onChange event handler when the input value changes", () => {
const expectedValue = generateKey();
const wrapper = createComponent(defaultProps);
wrapper.find("input").simulate("change", { target: { value: expectedValue } });
expect(onChangeHandler).toBeCalledWith(expectedValue);
expect(wrapper.state().value).toEqual(expectedValue);
});
it("toggles input type when clicking the visibility button", () => {
const wrapper = createComponent(defaultProps);
wrapper.find("button.btn-visibility").simulate("click");
expect(wrapper.find("input").prop("type")).toEqual("text");
expect(wrapper.state().showKey).toBe(true);
wrapper.find("button.btn-visibility").simulate("click");
expect(wrapper.find("input").prop("type")).toEqual("password");
expect(wrapper.state().showKey).toBe(false);
});
it("copies the input value to the clipboard when clicking on the copy button", () => {
const expectedValue = generateKey();
const wrapper = createComponent({
...defaultProps,
value: expectedValue,
});
wrapper.find("button.btn-copy").simulate("click");
const clipboard = (navigator as any).clipboard;
expect(clipboard.writeText).toBeCalledWith(expectedValue);
});
});

Просмотреть файл

@ -0,0 +1,95 @@
import React, { RefObject, SyntheticEvent } from "react";
/**
* Protected input properties
* @member value - The value to bind to the component
* @member securityToken - Optional value used to encrypt/decrypt the value
*/
export interface IProtectedInputProps extends React.Props<ProtectedInput> {
id: string;
value: string;
readOnly?: boolean;
onChange: (value: string) => void;
}
/** Protected input state
* @member showKey - Whether or not the input field renders as text or password field type
* @member decryptedValue - The decrypted value to bind to the input field
*/
export interface IProtectedInputState {
showKey: boolean;
value: string;
}
/**
* Protected input Component
* @description - Used for sensitive fields such as passwords, keys, tokens, etc
*/
export class ProtectedInput extends React.Component<IProtectedInputProps, IProtectedInputState> {
private inputElement: RefObject<HTMLInputElement> = React.createRef<HTMLInputElement>();
constructor(props) {
super(props);
this.state = {
showKey: false,
value: this.props.value,
};
this.toggleKeyVisibility = this.toggleKeyVisibility.bind(this);
this.copyKey = this.copyKey.bind(this);
this.onChange = this.onChange.bind(this);
}
public componentDidMount() {
this.props.onChange(this.props.value);
}
public render() {
const { id, readOnly } = this.props;
const { showKey, value } = this.state;
return (
<div className="input-group">
<input id={id}
ref={this.inputElement}
type={showKey ? "text" : "password"}
readOnly={readOnly}
className="form-control"
value={value}
onChange={this.onChange} />
<div className="input-group-append">
<button type="button"
title={showKey ? "Hide" : "Show"}
className="btn btn-primary btn-visibility"
onClick={this.toggleKeyVisibility}>
<i className={showKey ? "fas fa-eye-slash" : "fas fa-eye"}></i>
</button>
<button type="button"
title="Copy"
className="btn btn-primary btn-copy"
onClick={this.copyKey}>
<i className="fas fa-copy"></i>
</button>
</div>
</div>
);
}
private onChange(e: SyntheticEvent) {
const input = e.target as HTMLInputElement;
const value = input.value ? input.value : undefined;
this.setState({ value }, () => this.props.onChange(value));
}
private toggleKeyVisibility() {
this.setState({
showKey: !this.state.showKey,
});
}
private async copyKey() {
const clipboard = (navigator as any).clipboard;
await clipboard.writeText(this.inputElement.current.value);
}
}

Просмотреть файл

@ -0,0 +1,59 @@
import React from "react";
import { ISecurityTokenPickerProps, SecurityTokenPicker } from "./securityTokenPicker";
import { ReactWrapper, mount } from "enzyme";
import MockFactory from "../../../../common/mockFactory";
describe("Security Token Picker", () => {
const onChangeHandler = jest.fn();
const defaultProps: ISecurityTokenPickerProps = {
id: "security-token-picker",
value: "",
securityTokens: [],
onChange: onChangeHandler,
};
function createComponent(props: ISecurityTokenPickerProps): ReactWrapper<ISecurityTokenPickerProps> {
return mount(<SecurityTokenPicker {...props} />);
}
it("renders correctly", () => {
const wrapper = createComponent(defaultProps);
expect(wrapper.find("select").exists()).toBe(true);
expect(wrapper.find("select").prop("value")).toEqual("");
expect(wrapper.find("option").text()).toEqual("Generate New Security Token");
});
it("renders and selected correct value", () => {
const securityTokens = MockFactory.createSecurityTokens();
const expectedToken = securityTokens[1];
const props: ISecurityTokenPickerProps = {
...defaultProps,
value: expectedToken.name,
securityTokens,
};
const wrapper = createComponent(props);
expect(wrapper.find("select").prop("value")).toEqual(expectedToken.name);
});
it("renders a list of security tokens", () => {
const props = {
...defaultProps,
securityTokens: MockFactory.createSecurityTokens(),
};
const wrapper = createComponent(props);
expect(wrapper.find("option").length).toEqual(props.securityTokens.length + 1);
});
it("calls the onChange event handler when value changes", () => {
const props: ISecurityTokenPickerProps = {
...defaultProps,
securityTokens: MockFactory.createSecurityTokens(),
};
const expectedToken = props.securityTokens[1];
const wrapper = createComponent(props);
wrapper.find("select").simulate("change", { target: { value: expectedToken.name } });
expect(onChangeHandler).toBeCalledWith(expectedToken.name);
});
});

Просмотреть файл

@ -0,0 +1,45 @@
import React, { SyntheticEvent } from "react";
import { ISecurityToken } from "../../../../models/applicationState";
/**
* Security Token Picker Properties
* @member id - The id to bind to the input element
* @member value - The value to bind to the input element
* @member securityTokens - The list of security tokens to display
* @member onChange - The event handler to call when the input value changes
*/
export interface ISecurityTokenPickerProps {
id?: string;
value: string;
securityTokens: ISecurityToken[];
onChange: (value: string) => void;
}
/**
* Security Token Picker
* @description - Used to display a list of security tokens
*/
export class SecurityTokenPicker extends React.Component<ISecurityTokenPickerProps> {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
public render() {
return (
<select id={this.props.id}
className="form-control"
value={this.props.value}
onChange={this.onChange}>
<option value="">Generate New Security Token</option>
{this.props.securityTokens.map((item) => <option key={item.key} value={item.name}>{item.name}</option>)}
</select>
);
}
private onChange(e: SyntheticEvent) {
const inputElement = e.target as HTMLSelectElement;
this.props.onChange(inputElement.value ? inputElement.value : undefined);
}
}

Просмотреть файл

@ -1,10 +1,13 @@
import React from "react";
import { strings, addLocValues } from "../../../../common/strings";
import Form, { FormValidation } from "react-jsonschema-form";
import Form, { FormValidation, Widget } from "react-jsonschema-form";
import { ObjectFieldTemplate } from "../../common/objectField/objectFieldTemplate";
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
import { ArrayFieldTemplate } from "../../common/arrayField/arrayFieldTemplate";
import { IAppSettings } from "../../../../models/applicationState";
import { ProtectedInput } from "../../common/protectedInput/protectedInput";
import CustomField from "../../common/customField/customField";
import { generateKey } from "../../../../common/crypto";
// tslint:disable-next-line:no-var-requires
const formSchema = addLocValues(require("./appSettingsForm.json"));
// tslint:disable-next-line:no-var-requires
@ -24,6 +27,14 @@ export interface IAppSettingsFormState {
}
export class AppSettingsForm extends React.Component<IAppSettingsFormProps, IAppSettingsFormState> {
private fields = {
securityToken: CustomField(ProtectedInput, (props) => ({
id: props.idSchema.$id,
value: props.formData || generateKey(),
onChange: props.onChange,
})),
};
constructor(props: IAppSettingsFormProps) {
super(props);
@ -58,6 +69,7 @@ export class AppSettingsForm extends React.Component<IAppSettingsFormProps, IApp
showErrorList={false}
liveValidate={true}
noHtml5Validate={true}
fields={this.fields}
ObjectFieldTemplate={ObjectFieldTemplate}
FieldTemplate={CustomFieldTemplate}
ArrayFieldTemplate={ArrayFieldTemplate}

Просмотреть файл

@ -4,6 +4,11 @@
"addable": true,
"orderable": false,
"removable": true
},
"items": {
"key": {
"ui:field": "securityToken"
}
}
}
}
}

Просмотреть файл

@ -4,52 +4,53 @@ import MockFactory from "../../../../common/mockFactory";
import ConnectionForm, { IConnectionFormProps, IConnectionFormState } from "./connectionForm";
describe("Connection Form", () => {
const onSubmitHandler = jest.fn();
const testConnection = MockFactory.createTestConnection("test", "localFileSystemProxy");
let wrapper: ReactWrapper<IConnectionFormProps, IConnectionFormState> = null;
let wrapper: any = null;
let connectionForm: ReactWrapper<IConnectionFormProps, IConnectionFormState> = null;
function createComponent(props: IConnectionFormProps) {
function createComponent(props: IConnectionFormProps): ReactWrapper<IConnectionFormProps, IConnectionFormState> {
return mount(
<ConnectionForm {...props} />,
);
}
function init(): void {
beforeEach(() => {
wrapper = createComponent({
connection: MockFactory.createTestConnection("test", "localFileSystemProxy"),
onSubmit: jest.fn(),
});
expect(wrapper).not.toBeNull();
connectionForm = wrapper.find(ConnectionForm);
expect(connectionForm.exists()).toBe(true);
}
it("should update formData in state when changes occur", (done) => {
init();
connectionForm
.find("input#root_name")
.simulate("change", { target: { value: "Foo" } });
setImmediate(() => {
expect(connectionForm.state().formData.name).toEqual("Foo");
done();
connection: testConnection,
onSubmit: onSubmitHandler,
});
});
it("should update provider options when new type is set", (done) => {
init();
connectionForm
it("should update formData in state when changes occur", async () => {
const expected = "Test Value";
wrapper
.find("input#root_name")
.simulate("change", { target: { value: expected } });
await MockFactory.flushUi();
expect(wrapper.state().formData.name).toEqual(expected);
});
it("should update provider options when new type is set", async () => {
wrapper
.find("select#root_providerType")
.simulate("change", { target: { value: "bingImageSearch" } });
setImmediate(() => {
expect(connectionForm.state().formData.providerType).toEqual("bingImageSearch");
const providerOptions = connectionForm.state().formData.providerOptions;
expect("apiKey" in providerOptions).toBe(true);
expect("query" in providerOptions).toBe(true);
expect("aspectRatio" in providerOptions).toBe(true);
done();
});
await MockFactory.flushUi();
const providerOptions = wrapper.state().formData.providerOptions;
expect(wrapper.state().formData.providerType).toEqual("bingImageSearch");
expect("apiKey" in providerOptions).toBe(true);
expect("query" in providerOptions).toBe(true);
expect("aspectRatio" in providerOptions).toBe(true);
});
it("should call the onSubmit event handler when the form is submitted", async () => {
wrapper.find("form").simulate("submit");
await MockFactory.flushUi();
expect(onSubmitHandler).toBeCalledWith(testConnection);
});
});

Просмотреть файл

@ -5,6 +5,7 @@ import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker"
import { strings, addLocValues } from "../../../../common/strings";
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
import ConnectionProviderPicker from "../../common/connectionProviderPicker/connectionProviderPicker";
import { ProtectedInput } from "../../common/protectedInput/protectedInput";
// tslint:disable-next-line:no-var-requires
const formSchema = addLocValues(require("./connectionForm.json"));
// tslint:disable-next-line:no-var-requires
@ -45,6 +46,7 @@ export default class ConnectionForm extends React.Component<IConnectionFormProps
private widgets = {
localFolderPicker: (LocalFolderPicker as any) as Widget,
connectionProviderPicker: (ConnectionProviderPicker as any) as Widget,
protectedInput: (ProtectedInput as any) as Widget,
};
constructor(props, context) {

Просмотреть файл

@ -52,6 +52,7 @@ describe("Editor Page Component", () => {
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve({ ...project }));
projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve({ ...project }));
AssetProviderFactory.create = jest.fn(() => {
return {
@ -225,8 +226,8 @@ describe("Editor Page Component", () => {
const keyPressed = 2;
setImmediate(() => {
(editorPage.instance() as EditorPage).handleTagHotKey({ctrlKey: true, key: keyPressed.toString()});
expect(spy).toBeCalledWith({name: testProject.tags[keyPressed - 1].name});
(editorPage.instance() as EditorPage).handleTagHotKey({ ctrlKey: true, key: keyPressed.toString() });
expect(spy).toBeCalledWith({ name: testProject.tags[keyPressed - 1].name });
});
});

Просмотреть файл

@ -1,20 +1,20 @@
import React from "react";
import EditorSideBar, { IEditorSideBarProps } from "./editorSideBar";
import EditorSideBar, { IEditorSideBarProps, IEditorSideBarState } from "./editorSideBar";
import { ReactWrapper, mount } from "enzyme";
import { AutoSizer, List } from "react-virtualized";
import { IAsset, AssetState, AssetType } from "../../../../models/applicationState";
import MockFactory from "../../../../common/mockFactory";
describe("Editor SideBar", () => {
const onSelectAssetHanlder = jest.fn();
const testAssets = MockFactory.createTestAssets();
function createComponent(props: IEditorSideBarProps): ReactWrapper {
function createComponent(props: IEditorSideBarProps): ReactWrapper<IEditorSideBarProps, IEditorSideBarState> {
return mount(<EditorSideBar {...props} />);
}
it("Component renders correctly", () => {
const props: IEditorSideBarProps = {
assets: MockFactory.createTestAssets(),
assets: testAssets,
onAssetSelected: onSelectAssetHanlder,
};
@ -26,16 +26,15 @@ describe("Editor SideBar", () => {
it("Initializes state without asset selected", () => {
const props: IEditorSideBarProps = {
assets: MockFactory.createTestAssets(),
assets: testAssets,
onAssetSelected: onSelectAssetHanlder,
};
const wrapper = createComponent(props);
expect(wrapper.state("selectedAsset")).not.toBeDefined();
expect(wrapper.state().selectedAsset).not.toBeDefined();
});
it("Initializes state with asset selected", () => {
const testAssets = MockFactory.createTestAssets();
const props: IEditorSideBarProps = {
assets: testAssets,
selectedAsset: testAssets[0],
@ -43,11 +42,10 @@ describe("Editor SideBar", () => {
};
const wrapper = createComponent(props);
expect(wrapper.state("selectedAsset")).toEqual(props.selectedAsset);
expect(wrapper.state().selectedAsset).toEqual(props.selectedAsset);
});
it("Updates states after props have changed", (done) => {
const testAssets = MockFactory.createTestAssets();
const props: IEditorSideBarProps = {
assets: testAssets,
selectedAsset: null,
@ -60,13 +58,12 @@ describe("Editor SideBar", () => {
});
setImmediate(() => {
expect(wrapper.state("selectedAsset")).toEqual(testAssets[0]);
expect(wrapper.state().selectedAsset).toEqual(testAssets[0]);
done();
});
});
it("Calls onAssetSelected handler when an asset is selected", (done) => {
const testAssets = MockFactory.createTestAssets();
const props: IEditorSideBarProps = {
assets: testAssets,
selectedAsset: testAssets[0],
@ -80,8 +77,7 @@ describe("Editor SideBar", () => {
});
setImmediate(() => {
expect(wrapper.state()["selectedAsset"]).toEqual(nextAsset);
expect(onSelectAssetHanlder).toBeCalledWith(nextAsset);
expect(wrapper.state().selectedAsset).toEqual(nextAsset);
done();
});
});
@ -101,8 +97,7 @@ describe("Editor SideBar", () => {
});
setImmediate(() => {
expect(wrapper.state()["selectedAsset"]).toEqual(nextAsset);
expect(onSelectAssetHanlder).toBeCalledWith(nextAsset);
expect(wrapper.state().selectedAsset).toEqual(nextAsset);
done();
});
@ -113,8 +108,7 @@ describe("Editor SideBar", () => {
});
setImmediate(() => {
expect(backToOriginalwrapper.state()["selectedAsset"]).toEqual(originalAsset);
expect(onSelectAssetHanlder).toBeCalledWith(originalAsset);
expect(backToOriginalwrapper.state().selectedAsset).toEqual(originalAsset);
done();
});
});

Просмотреть файл

@ -78,13 +78,13 @@ export default class EditorSideBar extends React.Component<IEditorSideBarProps,
this.setState({
selectedAsset: asset,
}, () => {
this.props.onAssetSelected(asset);
this.listRef.current.forceUpdateGrid();
});
}
private onAssetClicked(asset: IAsset) {
this.selectAsset(asset);
this.props.onAssetSelected(asset);
}
private rowRenderer({ key, index, style }) {

Просмотреть файл

@ -7,6 +7,8 @@ import { ExportProviderFactory } from "../../../../providers/export/exportProvid
import ExportProviderPicker from "../../common/exportProviderPicker/exportProviderPicker";
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
import ExternalPicker from "../../common/externalPicker/externalPicker";
import { ProtectedInput } from "../../common/protectedInput/protectedInput";
import { ExportAssetState } from "../../../../providers/export/exportProvider";
// tslint:disable-next-line:no-var-requires
const formSchema = addLocValues(require("./exportForm.json"));
@ -49,6 +51,7 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
private widgets = {
externalPicker: (ExternalPicker as any) as Widget,
exportProviderPicker: (ExportProviderPicker as any) as Widget,
protectedInput: (ProtectedInput as any) as Widget,
};
constructor(props, context) {
@ -157,7 +160,7 @@ export default class ExportForm extends React.Component<IExportFormProps, IExpor
const formData = { ...exportFormat };
if (resetProviderOptions) {
formData.providerOptions = {};
formData.providerOptions = { assetState: ExportAssetState.All };
}
formData.providerType = providerType;

Просмотреть файл

@ -19,7 +19,7 @@ describe("Export Page", () => {
const exportProviderRegistrations = MockFactory.createExportProviderRegistrations();
let projectServiceMock: jest.Mocked<typeof ProjectService> = null;
function createComponent(store, props: IExportPageProps): ReactWrapper {
function createComponent(store, props: IExportPageProps): ReactWrapper<IExportPageProps> {
return mount(
<Provider store={store}>
<Router>
@ -40,6 +40,7 @@ describe("Export Page", () => {
beforeEach(() => {
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve(project));
});
it("Sets project state from redux store", () => {
@ -55,18 +56,18 @@ describe("Export Page", () => {
expect(exportPage.prop("project")).toEqual(testProject);
});
it("Sets project state from route params", (done) => {
it("Sets project state from route params", async (done) => {
const testProject = MockFactory.createTestProject("TestProject");
const store = createStore(testProject, false);
const props = createProps(testProject.id);
const loadProjectSpy = jest.spyOn(props.actions, "loadProject");
const wrapper = createComponent(store, props);
const exportPage = wrapper.find(ExportPage).childAt(0);
setImmediate(() => {
const exportPage = wrapper.find(ExportPage).childAt(0).instance() as ExportPage;
expect(exportPage.props.project).toEqual(testProject);
expect(loadProjectSpy).toHaveBeenCalledWith(testProject);
expect(exportPage.prop("project")).toEqual(testProject);
done();
});
});

Просмотреть файл

@ -6,6 +6,7 @@ import IProjectActions, * as projectActions from "../../../../redux/actions/proj
import ExportForm from "./exportForm";
import { IProject, IApplicationState, IExportFormat } from "../../../../models/applicationState";
import { strings } from "../../../../common/strings";
import { ExportAssetState } from "../../../../providers/export/exportProvider";
/**
* Properties for Export Page
@ -40,7 +41,9 @@ function mapDispatchToProps(dispatch) {
export default class ExportPage extends React.Component<IExportPageProps> {
private emptyExportFormat: IExportFormat = {
providerType: "",
providerOptions: {},
providerOptions: {
assetState: ExportAssetState.All,
},
};
constructor(props, context) {
@ -57,7 +60,9 @@ export default class ExportPage extends React.Component<IExportPageProps> {
}
public render() {
const exportFormat = this.props.project ? this.props.project.exportFormat : { ...this.emptyExportFormat };
const exportFormat = this.props.project && this.props.project.exportFormat
? this.props.project.exportFormat
: { ...this.emptyExportFormat };
return (
<div className="m-3 text-light">

Просмотреть файл

@ -5,6 +5,11 @@
"title": "${strings.common.displayName}",
"type": "string"
},
"securityToken": {
"title": "${strings.projectSettings.securityToken.title}",
"description": "${strings.projectSettings.securityToken.description}",
"type": "string"
},
"sourceConnection": {
"title": "${strings.projectSettings.sourceConnection.title}",
"description": "${strings.projectSettings.sourceConnection.description}",
@ -46,4 +51,4 @@
"targetConnection",
"videoSettings"
]
}
}

Просмотреть файл

@ -9,6 +9,7 @@ import { IProjectVideoSettings } from "../../../../models/applicationState";
describe("Project Form Component", () => {
const project = MockFactory.createTestProject("TestProject");
const appSettings = MockFactory.appSettings();
const connections = MockFactory.createTestConnections();
let wrapper: ReactWrapper<IProjectFormProps, IProjectFormState> = null;
let onSubmitHandler: jest.Mock = null;
@ -34,6 +35,7 @@ describe("Project Form Component", () => {
wrapper = createComponent({
project,
connections,
appSettings,
onSubmit: onSubmitHandler,
onCancel: onCancelHandler,
});
@ -172,6 +174,7 @@ describe("Project Form Component", () => {
const newWrapper = createComponent({
project,
appSettings,
connections: newConnections,
onSubmit: onSubmitHandler,
onCancel: onCancelHandler,
@ -193,6 +196,7 @@ describe("Project Form Component", () => {
onCancelHandler = jest.fn();
wrapper = createComponent({
project: null,
appSettings,
connections,
onSubmit: onSubmitHandler,
onCancel: onCancelHandler,

Просмотреть файл

@ -1,12 +1,14 @@
import React from "react";
import Form, { FormValidation, ISubmitEvent } from "react-jsonschema-form";
import { addLocValues, strings } from "../../../../common/strings";
import { IConnection, IProject } from "../../../../models/applicationState";
import { IConnection, IProject, IAppSettings } from "../../../../models/applicationState";
import ConnectionPicker from "../../common/connectionPicker/connectionPicker";
import CustomField from "../../common/customField/customField";
import CustomFieldTemplate from "../../common/customField/customFieldTemplate";
import TagsInput from "../../common/tagsInput/tagsInput";
import TagsInput, { ITagsInputProps } from "../../common/tagsInput/tagsInput";
import { StorageProviderFactory } from "../../../../providers/storage/storageProviderFactory";
import { SecurityTokenPicker, ISecurityTokenPickerProps } from "../../common/securityTokenPicker/securityTokenPicker";
import { IConnectionProviderPickerProps } from "../../common/connectionProviderPicker/connectionProviderPicker";
// tslint:disable-next-line:no-var-requires
const formSchema = addLocValues(require("./projectForm.json"));
// tslint:disable-next-line:no-var-requires
@ -22,6 +24,7 @@ const uiSchema = addLocValues(require("./projectForm.ui.json"));
export interface IProjectFormProps extends React.Props<ProjectForm> {
project: IProject;
connections: IConnection[];
appSettings: IAppSettings;
onSubmit: (project: IProject) => void;
onCancel?: () => void;
}
@ -46,15 +49,20 @@ export interface IProjectFormState {
*/
export default class ProjectForm extends React.Component<IProjectFormProps, IProjectFormState> {
private fields = {
sourceConnection: CustomField(ConnectionPicker, (props) => {
return {
id: props.idSchema.$id,
value: props.formData,
connections: this.props.connections,
onChange: props.onChange,
};
}),
targetConnection: CustomField(ConnectionPicker, (props) => {
securityToken: CustomField<ISecurityTokenPickerProps>(SecurityTokenPicker, (props) => ({
id: props.idSchema.$id,
schema: props.schema,
value: props.formData,
securityTokens: this.props.appSettings.securityTokens,
onChange: props.onChange,
})),
sourceConnection: CustomField<IConnectionProviderPickerProps>(ConnectionPicker, (props) => ({
id: props.idSchema.$id,
value: props.formData,
connections: this.props.connections,
onChange: props.onChange,
})),
targetConnection: CustomField<IConnectionProviderPickerProps>(ConnectionPicker, (props) => {
const targetConnections = this.props.connections.filter(
(connection) => StorageProviderFactory.isRegistered(connection.providerType));
return {
@ -64,12 +72,10 @@ export default class ProjectForm extends React.Component<IProjectFormProps, IPro
onChange: props.onChange,
};
}),
tagsInput: CustomField(TagsInput, (props) => {
return {
tags: props.formData,
onChange: props.onChange,
};
}),
tagsInput: CustomField<ITagsInputProps>(TagsInput, (props) => ({
tags: props.formData,
onChange: props.onChange,
})),
};
constructor(props, context) {

Просмотреть файл

@ -1,4 +1,7 @@
{
"securityToken": {
"ui:field": "securityToken"
},
"description": {
"ui:widget": "textarea"
},
@ -11,4 +14,4 @@
"tags": {
"ui:field": "tagsInput"
}
}
}

Просмотреть файл

@ -1,17 +1,14 @@
import { mount, ReactWrapper } from "enzyme";
import React from "react";
import { Provider } from "react-redux";
import { AnyAction, Store } from "redux";
import { BrowserRouter as Router } from "react-router-dom";
import MockFactory from "../../../../common/mockFactory";
import createReduxStore from "../../../../redux/store/store";
import { IApplicationState } from "../../../../models/applicationState";
import initialState from "../../../../redux/store/initialState";
import ProjectSettingsPage, { IProjectSettingsPageProps } from "./projectSettingsPage";
jest.mock("../../../../services/projectService");
import ProjectService from "../../../../services/projectService";
import { ConformsPredicateObject } from "lodash";
import { IAppSettings } from "../../../../models/applicationState";
describe("Project settings page", () => {
let projectServiceMock: jest.Mocked<typeof ProjectService> = null;
@ -28,15 +25,19 @@ describe("Project settings page", () => {
beforeEach(() => {
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.load = jest.fn((project) => ({ ...project }));
});
it("Form submission calls save project action", (done) => {
const store = createReduxStore(MockFactory.initialState());
const props = MockFactory.projectSettingsProps();
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
const wrapper = createCompoent(store, props);
wrapper.find("form").simulate("submit");
setImmediate(() => {
expect(saveProjectSpy).toBeCalled();
done();
@ -44,7 +45,6 @@ describe("Project settings page", () => {
});
it("Throws an error when a user tries to create a duplicate project", async (done) => {
const context: any = null;
const project = MockFactory.createTestProject("1");
project.id = "25";
const initialStateOverride = {
@ -52,7 +52,7 @@ describe("Project settings page", () => {
};
const store = createReduxStore(MockFactory.initialState(initialStateOverride));
const props = MockFactory.projectSettingsProps();
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
const wrapper = createCompoent(store, props);
wrapper.setProps({
@ -64,7 +64,7 @@ describe("Project settings page", () => {
},
},
actions: {
saveProject: props.actions.saveProject,
saveProject: props.projectActions.saveProject,
},
});
wrapper.find("form").simulate("submit");
@ -76,26 +76,38 @@ describe("Project settings page", () => {
});
it("calls save project when user creates a unique project", (done) => {
const store = createReduxStore(MockFactory.initialState());
const initialState = MockFactory.initialState();
// New Project should not have id or security token set by default
const project = { ...initialState.recentProjects[0] };
project.id = null;
project.name = "Brand New Project";
project.securityToken = "";
// Override currentProject to load the form values
initialState.currentProject = project;
const store = createReduxStore(initialState);
const props = MockFactory.projectSettingsProps();
const saveProjectSpy = jest.spyOn(props.actions, "saveProject");
const project = MockFactory.createTestProject("25");
const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject");
const saveAppSettingsSpy = jest.spyOn(props.applicationActions, "saveAppSettings");
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
const wrapper = createCompoent(store, props);
wrapper.setProps({
form: {
name: project.name,
connections: {
source: project.sourceConnection,
target: project.targetConnection,
},
},
});
wrapper.find("form").simulate("submit");
setImmediate(() => {
expect(saveProjectSpy).toBeCalled();
// New security token was created for new project
expect(saveAppSettingsSpy).toBeCalled();
const appSettings = saveAppSettingsSpy.mock.calls[0][0] as IAppSettings;
expect(appSettings.securityTokens.length).toEqual(initialState.appSettings.securityTokens.length + 1);
// New project was saved with new security token
expect(saveProjectSpy).toBeCalledWith({
...project,
securityToken: `${project.name} Token`,
});
done();
});
});

Просмотреть файл

@ -3,9 +3,11 @@ import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import { RouteComponentProps } from "react-router-dom";
import ProjectForm from "./projectForm";
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
import { IApplicationState, IProject, IConnection } from "../../../../models/applicationState";
import { strings } from "../../../../common/strings";
import IProjectActions, * as projectActions from "../../../../redux/actions/projectActions";
import { IApplicationState, IProject, IConnection, IAppSettings } from "../../../../models/applicationState";
import IApplicationActions, * as applicationActions from "../../../../redux/actions/applicationActions";
import { generateKey } from "../../../../common/crypto";
/**
* Properties for Project Settings Page
@ -17,8 +19,10 @@ import { strings } from "../../../../common/strings";
export interface IProjectSettingsPageProps extends RouteComponentProps, React.Props<ProjectSettingsPage> {
project: IProject;
recentProjects: IProject[];
actions: IProjectActions;
projectActions: IProjectActions;
applicationActions: IApplicationActions;
connections: IConnection[];
appSettings: IAppSettings;
}
function mapStateToProps(state: IApplicationState) {
@ -26,12 +30,14 @@ function mapStateToProps(state: IApplicationState) {
project: state.currentProject,
connections: state.connections,
recentProjects: state.recentProjects,
appSettings: state.appSettings,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(projectActions, dispatch),
projectActions: bindActionCreators(projectActions, dispatch),
applicationActions: bindActionCreators(applicationActions, dispatch),
};
}
@ -47,7 +53,7 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
const projectId = this.props.match.params["projectId"];
if (!this.props.project && projectId) {
const project = this.props.recentProjects.find((project) => project.id === projectId);
this.props.actions.loadProject(project);
this.props.projectActions.loadProject(project);
}
this.onFormSubmit = this.onFormSubmit.bind(this);
@ -67,6 +73,7 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
<ProjectForm
project={this.props.project}
connections={this.props.connections}
appSettings={this.props.appSettings}
onSubmit={this.onFormSubmit}
onCancel={this.onFormCancel} />
</div>
@ -74,14 +81,12 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
);
}
private onFormSubmit = async (formData) => {
const projectToUpdate: IProject = {
...formData,
};
private onFormSubmit = async (project: IProject) => {
const isNew = !(!!project.id);
await this.props.actions.saveProject(projectToUpdate);
await this.ensureSecurityToken(project);
await this.props.projectActions.saveProject(project);
const isNew = !(!!projectToUpdate.id);
if (isNew) {
this.props.history.push(`/projects/${this.props.project.id}/edit`);
} else {
@ -92,4 +97,32 @@ export default class ProjectSettingsPage extends React.Component<IProjectSetting
private onFormCancel() {
this.props.history.goBack();
}
/**
* Ensures that a valid security token is associated with the project, otherwise creates one
* @param project The project to validate
*/
private async ensureSecurityToken(project: IProject): Promise<IProject> {
let securityToken = this.props.appSettings.securityTokens
.find((st) => st.name === project.securityToken);
if (securityToken) {
return project;
}
securityToken = {
name: `${project.name} Token`,
key: generateKey(),
};
const updatedAppSettings: IAppSettings = {
devToolsEnabled: this.props.appSettings.devToolsEnabled,
securityTokens: [...this.props.appSettings.securityTokens, securityToken],
};
await this.props.applicationActions.saveAppSettings(updatedAppSettings);
project.securityToken = securityToken.name;
return project;
}
}

Просмотреть файл

@ -11,17 +11,29 @@ jest.mock("../../services/assetService");
import { AssetService } from "../../services/assetService";
import { ExportProviderFactory } from "../../providers/export/exportProviderFactory";
import { IExportProvider } from "../../providers/export/exportProvider";
import { IApplicationState } from "../../models/applicationState";
import initialState from "../store/initialState";
describe("Project Redux Actions", () => {
let store: MockStoreEnhanced;
let store: MockStoreEnhanced<IApplicationState>;
let projectServiceMock: jest.Mocked<typeof ProjectService>;
const appSettings = MockFactory.appSettings();
beforeEach(() => {
const middleware = [thunk];
store = createMockStore(middleware)();
const mockState: IApplicationState = {
...initialState,
appSettings,
};
store = createMockStore<IApplicationState>(middleware)(mockState);
projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.load = jest.fn((project) => Promise.resolve(project));
});
it("Load Project action resolves a promise and dispatches redux action", async () => {
const project = MockFactory.createTestProject("Project1");
const result = await projectActions.loadProject(project)(store.dispatch);
const project = MockFactory.createTestProject("TestProject");
const securityToken = appSettings.securityTokens.find((st) => st.name === project.securityToken);
const result = await projectActions.loadProject(project)(store.dispatch, store.getState);
const actions = store.getActions();
expect(actions.length).toEqual(1);
@ -30,30 +42,35 @@ describe("Project Redux Actions", () => {
payload: project,
});
expect(result).toEqual(project);
expect(projectServiceMock.prototype.load).toBeCalledWith(project, securityToken);
});
it("Save Project action calls project service and dispatches redux action", async () => {
const projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project));
const project = MockFactory.createTestProject("Project1");
const project = MockFactory.createTestProject("TestProject");
const securityToken = appSettings.securityTokens.find((st) => st.name === project.securityToken);
const result = await projectActions.saveProject(project)(store.dispatch, store.getState);
const actions = store.getActions();
expect(actions.length).toEqual(1);
expect(actions.length).toEqual(2);
expect(actions[0]).toEqual({
type: ActionTypes.SAVE_PROJECT_SUCCESS,
payload: project,
});
expect(actions[1]).toEqual({
type: ActionTypes.LOAD_PROJECT_SUCCESS,
payload: project,
});
expect(result).toEqual(project);
expect(projectServiceMock.prototype.save).toBeCalledWith(project);
expect(projectServiceMock.prototype.save).toBeCalledWith(project, securityToken);
expect(projectServiceMock.prototype.load).toBeCalledWith(project, securityToken);
});
it("Delete Project action calls project service and dispatches redux action", async () => {
const projectServiceMock = ProjectService as jest.Mocked<typeof ProjectService>;
projectServiceMock.prototype.delete = jest.fn(() => Promise.resolve());
const project = MockFactory.createTestProject("Project1");
const project = MockFactory.createTestProject("TestProject");
await projectActions.deleteProject(project)(store.dispatch);
const actions = store.getActions();
@ -80,7 +97,7 @@ describe("Project Redux Actions", () => {
const mockAssetService = AssetService as jest.Mocked<typeof AssetService>;
mockAssetService.prototype.getAssets = jest.fn(() => Promise.resolve(testAssets));
const project = MockFactory.createTestProject("Project1");
const project = MockFactory.createTestProject("TestProject");
const results = await projectActions.loadAssets(project)(store.dispatch);
const actions = store.getActions();
@ -100,7 +117,7 @@ describe("Project Redux Actions", () => {
const mockAssetService = AssetService as jest.Mocked<typeof AssetService>;
mockAssetService.prototype.getAssetMetadata = jest.fn(() => assetMetadata);
const project = MockFactory.createTestProject("Project1");
const project = MockFactory.createTestProject("TestProject");
const result = await projectActions.loadAssetMetadata(project, asset)(store.dispatch);
const actions = store.getActions();
@ -120,7 +137,7 @@ describe("Project Redux Actions", () => {
const mockAssetService = AssetService as jest.Mocked<typeof AssetService>;
mockAssetService.prototype.save = jest.fn(() => assetMetadata);
const project = MockFactory.createTestProject("Project1");
const project = MockFactory.createTestProject("TestProject");
const result = await projectActions.saveAssetMetadata(project, assetMetadata)(store.dispatch);
const actions = store.getActions();
@ -141,7 +158,7 @@ describe("Project Redux Actions", () => {
};
ExportProviderFactory.create = jest.fn(() => mockExportProvider);
const project = MockFactory.createTestProject("Project1");
const project = MockFactory.createTestProject("TestProject");
await projectActions.exportProject(project)(store.dispatch);
const actions = store.getActions();

Просмотреть файл

@ -1,11 +1,16 @@
import { Dispatch, Action } from "redux";
import ProjectService from "../../services/projectService";
import { IProject, IAsset, IAssetMetadata } from "../../models/applicationState";
import {
IProject, IAsset, IAssetMetadata, IApplicationState,
ISecurityToken, IAppSettings,
} from "../../models/applicationState";
import { ActionTypes } from "./actionTypes";
import { AssetService } from "../../services/assetService";
import { ExportProviderFactory } from "../../providers/export/exportProviderFactory";
import { createPayloadAction, IPayloadAction, createAction } from "./actionCreators";
import { IExportResults } from "../../providers/export/exportProvider";
import { generateKey } from "../../common/crypto";
import { saveAppSettingsAction } from "./applicationActions";
/**
* Actions to be performed in relation to projects
@ -25,10 +30,23 @@ export default interface IProjectActions {
* Dispatches Load Project action and resolves with IProject
* @param project - Project to load
*/
export function loadProject(project: IProject): (dispatch: Dispatch) => Promise<IProject> {
return (dispatch: Dispatch) => {
dispatch(loadProjectAction(project));
return Promise.resolve(project);
export function loadProject(project: IProject):
(dispatch: Dispatch, getState: () => IApplicationState) => Promise<IProject> {
return async (dispatch: Dispatch, getState: () => IApplicationState) => {
const appState = getState();
const projectService = new ProjectService();
// Lookup security token used to decrypt project settings
const securityToken = appState.appSettings.securityTokens
.find((st) => st.name === project.securityToken);
if (!securityToken) {
throw new Error(`Cannot locate security token '${project.securityToken}' from project`);
}
const loadedProject = await projectService.load(project, securityToken);
dispatch(loadProjectAction(loadedProject));
return loadedProject;
};
}
@ -37,17 +55,30 @@ export function loadProject(project: IProject): (dispatch: Dispatch) => Promise<
* @param project - Project to save
*/
export function saveProject(project: IProject):
(dispatch: Dispatch, getState: any) => Promise<IProject> {
return async (dispatch: Dispatch, getState: any) => {
(dispatch: Dispatch, getState: () => IApplicationState) => Promise<IProject> {
return async (dispatch: Dispatch, getState: () => IApplicationState) => {
const appState = getState();
const projectService = new ProjectService();
const projectList = getState().recentProjects;
if (!projectService.isDuplicate(project, projectList)) {
project = await projectService.save(project);
dispatch(saveProjectAction(project));
} else {
throw new Error("Cannot create duplicate projects");
if (projectService.isDuplicate(project, appState.recentProjects)) {
throw new Error(`Project with name '${project.name}
already exists with the same target connection '${project.targetConnection.name}'`);
}
return project;
const securityToken = appState.appSettings.securityTokens
.find((st) => st.name === project.securityToken);
if (!securityToken) {
throw new Error(`Cannot locate security token '${project.securityToken}' from project`);
}
const savedProject = await projectService.save(project, securityToken);
dispatch(saveProjectAction(savedProject));
// Reload project after save actions
await loadProject(savedProject)(dispatch, getState);
return savedProject;
};
}

Просмотреть файл

@ -23,27 +23,6 @@ describe("Current Project Reducer", () => {
expect(result).toEqual(testProject);
});
it("Save project updates current project when project ids matches", () => {
const currentProject = MockFactory.createTestProject("TestProject");
const updatedProject = { ...currentProject, name: currentProject.name += "Updated" };
const state: IProject = currentProject;
const action = saveProjectAction(updatedProject);
const result = reducer(state, action);
expect(result).not.toBe(state);
expect(result).toEqual(updatedProject);
});
it("Save project is noop when project ids do not match", () => {
const currentProject = MockFactory.createTestProject("1");
const updatedProject = MockFactory.createTestProject("2");
const state: IProject = currentProject;
const action = saveProjectAction(updatedProject);
const result = reducer(state, action);
expect(result).toBe(state);
});
it("Close project clears out current project", () => {
const currentProject = MockFactory.createTestProject("1");
const state: IProject = currentProject;

Просмотреть файл

@ -21,12 +21,6 @@ export const reducer = (state: IProject = null, action: AnyAction): IProject =>
return null;
case ActionTypes.LOAD_PROJECT_SUCCESS:
return { ...action.payload };
case ActionTypes.SAVE_PROJECT_SUCCESS:
if (!state || state.id === action.payload.id) {
return { ...action.payload };
} else {
return state;
}
case ActionTypes.LOAD_PROJECT_ASSETS_SUCCESS:
if (state) {
const currentAssets = { ...state.assets } || {};

Просмотреть файл

@ -20,7 +20,6 @@ export const reducer = (state: IProject[] = [], action: AnyAction): IProject[] =
let newState: IProject[] = null;
switch (action.type) {
case ActionTypes.LOAD_PROJECT_SUCCESS:
case ActionTypes.SAVE_PROJECT_SUCCESS:
return [
{ ...action.payload },

Просмотреть файл

@ -1,15 +1,17 @@
import ProjectService, { IProjectService } from "./projectService";
import MockFactory from "../common/mockFactory";
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
import { IProject, IExportFormat } from "../models/applicationState";
import { error } from "util";
import { IProject, IExportFormat, ISecurityToken } from "../models/applicationState";
import { constants } from "../common/constants";
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
import { generateKey } from "../common/crypto";
import { encryptProject } from "../common/utils";
describe("Project Service", () => {
let projectSerivce: IProjectService = null;
let testProject: IProject = null;
let projectList: IProject[] = null;
let securityToken: ISecurityToken = null;
const storageProviderMock = {
writeText: jest.fn((project) => Promise.resolve(project)),
@ -25,14 +27,36 @@ describe("Project Service", () => {
ExportProviderFactory.create = jest.fn(() => exportProviderMock);
beforeEach(() => {
securityToken = {
name: "TestToken",
key: generateKey(),
};
testProject = MockFactory.createTestProject("TestProject");
projectSerivce = new ProjectService();
});
it("Saves calls project storage provider to write project", async () => {
const result = await projectSerivce.save(testProject);
it("Load decrypts any project settings using the specified key", async () => {
const encryptedProject = encryptProject(testProject, securityToken);
const decryptedProject = await projectSerivce.load(encryptedProject, securityToken);
expect(result).toEqual(testProject);
expect(decryptedProject).toEqual(testProject);
});
it("Saves calls project storage provider to write project", async () => {
const result = await projectSerivce.save(testProject, securityToken);
const encryptedProject: IProject = { ...testProject };
encryptedProject.sourceConnection.providerOptions = {
encrypted: expect.any(String),
};
encryptedProject.targetConnection.providerOptions = {
encrypted: expect.any(String),
};
encryptedProject.exportFormat.providerOptions = {
encrypted: expect.any(String),
};
expect(result).toEqual(encryptedProject);
expect(StorageProviderFactory.create).toBeCalledWith(
testProject.targetConnection.providerType,
testProject.targetConnection.providerOptions,
@ -46,12 +70,11 @@ describe("Project Service", () => {
it("Save calls configured export provider save when defined", async () => {
testProject.exportFormat = {
providerType: "azureCustomVision",
providerOptions: {},
providerOptions: null,
};
const result = await projectSerivce.save(testProject);
await projectSerivce.save(testProject, securityToken);
expect(result).toEqual(testProject);
expect(ExportProviderFactory.create).toBeCalledWith(
testProject.exportFormat.providerType,
testProject,
@ -63,7 +86,7 @@ describe("Project Service", () => {
it("Save throws error if writing to storage provider fails", async () => {
const expectedError = "Error writing to storage provider";
storageProviderMock.writeText.mockImplementationOnce(() => Promise.reject(expectedError));
await expect(projectSerivce.save(testProject)).rejects.toEqual(expectedError);
await expect(projectSerivce.save(testProject, securityToken)).rejects.toEqual(expectedError);
});
it("Save throws error if storage provider cannot be created", async () => {
@ -71,7 +94,7 @@ describe("Project Service", () => {
const createMock = StorageProviderFactory.create as jest.Mock;
createMock.mockImplementationOnce(() => { throw expectedError; });
await expect(projectSerivce.save(testProject)).rejects.toEqual(expectedError);
await expect(projectSerivce.save(testProject, securityToken)).rejects.toEqual(expectedError);
});
it("Delete calls project storage provider to delete project", async () => {

Просмотреть файл

@ -1,9 +1,10 @@
import shortid from "shortid";
import { StorageProviderFactory } from "../providers/storage/storageProviderFactory";
import { IProject } from "../models/applicationState";
import { IProject, ISecurityToken } from "../models/applicationState";
import Guard from "../common/guard";
import { constants } from "../common/constants";
import { ExportProviderFactory } from "../providers/export/exportProviderFactory";
import { decryptProject, encryptProject } from "../common/utils";
/**
* Functions required for a project service
@ -11,7 +12,8 @@ import { ExportProviderFactory } from "../providers/export/exportProviderFactory
* @member delete - Delete a project
*/
export interface IProjectService {
save(project: IProject): Promise<IProject>;
load(project: IProject, securityToken: ISecurityToken): Promise<IProject>;
save(project: IProject, securityToken: ISecurityToken): Promise<IProject>;
delete(project: IProject): Promise<void>;
isDuplicate(project: IProject, projectList: IProject[]): boolean;
}
@ -21,12 +23,24 @@ export interface IProjectService {
* @description - Functions for dealing with projects
*/
export default class ProjectService implements IProjectService {
/**
* Loads a project
* @param project The project JSON to load
* @param securityToken The security token used to decrypt sensitive project settings
*/
public load(project: IProject, securityToken: ISecurityToken): Promise<IProject> {
Guard.null(project);
const loadedProject = decryptProject(project, securityToken);
return Promise.resolve(loadedProject);
}
/**
* Save a project
* @param project - Project to save
*/
public save(project: IProject) {
public save(project: IProject, securityToken: ISecurityToken): Promise<IProject> {
Guard.null(project);
return new Promise<IProject>(async (resolve, reject) => {
@ -35,17 +49,15 @@ export default class ProjectService implements IProjectService {
project.id = shortid.generate();
}
const storageProvider = StorageProviderFactory.create(
project.targetConnection.providerType,
project.targetConnection.providerOptions,
);
const storageProvider = StorageProviderFactory.createFromConnection(project.targetConnection);
await this.saveExportSettings(project);
project = encryptProject(project, securityToken);
await this.saveProjectFile(project);
await storageProvider.writeText(
`${project.name}${constants.projectFileExtension}`,
JSON.stringify(project, null, 4));
JSON.stringify(project, null, 4),
);
resolve(project);
} catch (err) {
@ -58,7 +70,7 @@ export default class ProjectService implements IProjectService {
* Delete a project
* @param project - Project to delete
*/
public delete(project: IProject) {
public delete(project: IProject): Promise<void> {
Guard.null(project);
return new Promise<void>(async (resolve, reject) => {
@ -82,7 +94,7 @@ export default class ProjectService implements IProjectService {
p.id !== project.id &&
p.name === project.name &&
JSON.stringify(p.targetConnection.providerOptions) ===
JSON.stringify(project.targetConnection.providerOptions),
JSON.stringify(project.targetConnection.providerOptions),
);
return (duplicateProjects !== undefined);
}