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:
Родитель
50bd9a3d86
Коммит
ed98ef2ce3
|
@ -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);
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче