From f731dfaecea65b5e3abfcace9e7960aa0d8471b6 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 3 Nov 2020 11:34:17 -0800 Subject: [PATCH] feat: Make security tokens optional (#1022) Security tokens are a great way to protect sensitive data from being stored in plain text within your project file, but at the same time can be more difficult to use when sharing projects with team members. In this feature security tokens are still enabled by default but give the project author the ability to turn off security tokens which then stores all provider options in plain text. --- azure-pipelines.yml | 3 + config/webpack.dev.js | 2 +- src/common/localization/en-us.ts | 4 ++ src/common/localization/es-cl.ts | 4 ++ src/common/localization/ja.ts | 5 ++ src/common/localization/ko-kr.ts | 5 ++ src/common/localization/zh-ch.ts | 5 ++ src/common/localization/zh-tw.ts | 5 ++ src/common/mockFactory.ts | 1 + src/common/strings.ts | 4 ++ src/common/utils.ts | 2 +- src/models/applicationState.ts | 3 +- .../pages/projectSettings/projectForm.json | 40 ++++++++++-- .../projectSettings/projectForm.test.tsx | 4 +- .../pages/projectSettings/projectForm.tsx | 8 ++- .../pages/projectSettings/projectForm.ui.json | 3 + .../projectSettingsPage.test.tsx | 8 +++ .../projectSettings/projectSettingsPage.tsx | 20 ++++-- src/redux/actions/projectActions.ts | 10 ++- src/services/importService.ts | 1 + src/services/projectService.test.ts | 64 +++++++++++++------ src/services/projectService.ts | 16 +++-- 22 files changed, 166 insertions(+), 51 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 9b6919c..8be5e80 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -5,6 +5,9 @@ pr: - dev* # kick off for pr targeting dev or prefix dev - master # trigger build for pr targeting master +variables: +- group: CODE_COV + jobs: - job: Linux pool: diff --git a/config/webpack.dev.js b/config/webpack.dev.js index 7c4f278..feb5341 100644 --- a/config/webpack.dev.js +++ b/config/webpack.dev.js @@ -4,4 +4,4 @@ const common = require('./webpack.common.js') module.exports = merge(common, { mode: 'development', devtool: "inline-source-map", -}) \ No newline at end of file +}) diff --git a/src/common/localization/en-us.ts b/src/common/localization/en-us.ts index cd5b7a8..0f8833c 100644 --- a/src/common/localization/en-us.ts +++ b/src/common/localization/en-us.ts @@ -84,6 +84,10 @@ export const english: IAppStrings = { title: "Security Token", description: "Used to encrypt sensitive data within project files", }, + useSecurityToken: { + title: "Use Security Token", + description: "When enabled will encrypt sensitive data within provider configuration", + }, save: "Save Project", sourceConnection: { title: "Source Connection", diff --git a/src/common/localization/es-cl.ts b/src/common/localization/es-cl.ts index 5f6f785..3508fb1 100644 --- a/src/common/localization/es-cl.ts +++ b/src/common/localization/es-cl.ts @@ -85,6 +85,10 @@ export const spanish: IAppStrings = { title: "Token de seguridad", description: "Se utiliza para cifrar datos confidenciales dentro de archivos de proyecto", }, + useSecurityToken: { + title: "Usar Token de Seguridad", + description: "Si está habilitado, los datos confidenciales se cifrarán", + }, save: "Guardar el Proyecto", sourceConnection: { title: "Conexión de Origen", diff --git a/src/common/localization/ja.ts b/src/common/localization/ja.ts index 0e566f5..c5c6f48 100644 --- a/src/common/localization/ja.ts +++ b/src/common/localization/ja.ts @@ -86,6 +86,11 @@ export const japanese: IAppStrings = { title: "セキュリティ トークン", // Security Token, description: "プロジェクト ファイル内の機密データを暗号化するために使用されます", // Used to encrypt sensitive data within project file" }, + useSecurityToken: { + title: "セキュリティ トークン", // Use Security Token + description: "有効にすると、プロバイダー構成内の機密データが暗号化されます。", + // When enabled will encrypt sensitive data within provider configuration + }, save: "プロジェクトを保存", // Save Project, sourceConnection: { title: "ソース接続", // Source Connection, diff --git a/src/common/localization/ko-kr.ts b/src/common/localization/ko-kr.ts index c2d87b4..35b83ea 100644 --- a/src/common/localization/ko-kr.ts +++ b/src/common/localization/ko-kr.ts @@ -86,6 +86,11 @@ export const korean: IAppStrings = { title: "보안 토큰", // Security Token, description: "프로젝트 파일 내에서 중요한 데이터를 암호화하는 데 사용", // Used to encrypt sensitive data within project file }, + useSecurityToken: { + title: "보안 토큰 사용", // Use Security Token + description: "활성화되면 공급자 구성 내에서 중요한 데이터를 암호화합니다.", + // When enabled will encrypt sensitive data within provider configuration + }, save: "프로젝트 저장", // Save Project, sourceConnection: { title: "소스 연결", // Source Connection, diff --git a/src/common/localization/zh-ch.ts b/src/common/localization/zh-ch.ts index 142644e..2c1e604 100644 --- a/src/common/localization/zh-ch.ts +++ b/src/common/localization/zh-ch.ts @@ -86,6 +86,11 @@ export const chinese: IAppStrings = { title: "安全令牌", // Security Token description: "用于加密项目文件中的敏感数据", // Used to encrypt sensitive data within project files }, + useSecurityToken: { + title: "使用安全令牌", // Use Security Token + description: "启用后将在提供者配置内加密敏感数据", + // When enabled will encrypt sensitive data within provider configuration + }, save: "保存项目", // Save Project sourceConnection: { title: "源连接", // Source Connection diff --git a/src/common/localization/zh-tw.ts b/src/common/localization/zh-tw.ts index d1081a7..c313cd6 100644 --- a/src/common/localization/zh-tw.ts +++ b/src/common/localization/zh-tw.ts @@ -86,6 +86,11 @@ export const chinesetw: IAppStrings = { title: "安全性權杖", // Security Token description: "用於加密專案檔案中的敏感資料", // Used to encrypt sensitive data within project files }, + useSecurityToken: { + title: "使用安全令牌", // Use Security Token + description: "啟用後將在提供者配置內加密敏感數據", + // When enabled will encrypt sensitive data within provider configuration + }, save: "保存專案", // Save Project sourceConnection: { title: "來源連線", // Source Connection diff --git a/src/common/mockFactory.ts b/src/common/mockFactory.ts index f3902f0..6bfd266 100644 --- a/src/common/mockFactory.ts +++ b/src/common/mockFactory.ts @@ -277,6 +277,7 @@ export default class MockFactory { id: `project-${name}`, name: `Project ${name}`, version: appInfo.version, + useSecurityToken: true, securityToken: `Security-Token-${name}`, assets: {}, exportFormat: MockFactory.exportFormat(), diff --git a/src/common/strings.ts b/src/common/strings.ts index 103af92..29398d2 100644 --- a/src/common/strings.ts +++ b/src/common/strings.ts @@ -90,6 +90,10 @@ export interface IAppStrings { title: string; description: string; }, + useSecurityToken: { + title: string; + description: string; + }, save: string; sourceConnection: { title: string; diff --git a/src/common/utils.ts b/src/common/utils.ts index 4b487a5..d656592 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -59,7 +59,7 @@ export function encodeFileURI(path: string, additionalEncodings?: boolean): stri const encodings = { "\#": "%23", "\?": "%3F", - }; + }; const encodedURI = `file:${encodeURI(normalizeSlashes(path))}`; if (additionalEncodings) { return encodedURI.replace(matchString, (match) => encodings[match]); diff --git a/src/models/applicationState.ts b/src/models/applicationState.ts index 41b11bb..077c969 100644 --- a/src/models/applicationState.ts +++ b/src/models/applicationState.ts @@ -107,7 +107,8 @@ export interface IProject { id: string; name: string; version: string; - securityToken: string; + useSecurityToken: boolean; + securityToken?: string; description?: string; tags: ITag[]; sourceConnection: IConnection; diff --git a/src/react/components/pages/projectSettings/projectForm.json b/src/react/components/pages/projectSettings/projectForm.json index 70ba5b9..793bd5e 100644 --- a/src/react/components/pages/projectSettings/projectForm.json +++ b/src/react/components/pages/projectSettings/projectForm.json @@ -6,11 +6,6 @@ "type": "string", "pattern": "^[^\\\\\\\\/:*?\\\\\\\"<>|]*$" }, - "securityToken": { - "title": "${strings.projectSettings.securityToken.title}", - "description": "${strings.projectSettings.securityToken.description}", - "type": "string" - }, "sourceConnection": { "title": "${strings.projectSettings.sourceConnection.title}", "description": "${strings.projectSettings.sourceConnection.description}", @@ -44,6 +39,41 @@ "tags": { "title": "${strings.tags.title}", "type": "array" + }, + "useSecurityToken": { + "title": "${strings.projectSettings.useSecurityToken.title}", + "description": "${strings.projectSettings.useSecurityToken.description}", + "type": "boolean", + "default": true + } + }, + "dependencies": { + "useSecurityToken": { + "oneOf": [ + { + "properties": { + "useSecurityToken": { + "enum": [ + true + ] + }, + "securityToken": { + "title": "${strings.projectSettings.securityToken.title}", + "description": "${strings.projectSettings.securityToken.description}", + "type": "string" + } + } + }, + { + "properties": { + "useSecurityToken": { + "enum": [ + false + ] + } + } + } + ] } }, "required": [ diff --git a/src/react/components/pages/projectSettings/projectForm.test.tsx b/src/react/components/pages/projectSettings/projectForm.test.tsx index 12103c3..39e57d4 100644 --- a/src/react/components/pages/projectSettings/projectForm.test.tsx +++ b/src/react/components/pages/projectSettings/projectForm.test.tsx @@ -263,8 +263,8 @@ describe("Project Form Component", () => { onCancel: onCancelHandler, }); const newTagName = "My new tag"; - wrapper.find("input").last().simulate("change", { target: { value: newTagName } }); - wrapper.find("input").last().simulate("keyDown", { keyCode: 13 }); + wrapper.find("input#tagInputField").last().simulate("change", { target: { value: newTagName } }); + wrapper.find("input#tagInputField").last().simulate("keyDown", { keyCode: 13 }); const tags = wrapper.state().formData.tags; expect(tags).toHaveLength(1); diff --git a/src/react/components/pages/projectSettings/projectForm.tsx b/src/react/components/pages/projectSettings/projectForm.tsx index 0cfc8da..bde1ad0 100644 --- a/src/react/components/pages/projectSettings/projectForm.tsx +++ b/src/react/components/pages/projectSettings/projectForm.tsx @@ -5,10 +5,11 @@ import { addLocValues, strings } from "../../../../common/strings"; import { IConnection, IProject, ITag, IAppSettings } from "../../../../models/applicationState"; import { StorageProviderFactory } from "../../../../providers/storage/storageProviderFactory"; import { ConnectionPickerWithRouter } from "../../common/connectionPicker/connectionPicker"; -import { CustomField } from "../../common/customField/customField"; +import { CustomField, CustomWidget } from "../../common/customField/customField"; import CustomFieldTemplate from "../../common/customField/customFieldTemplate"; import { ISecurityTokenPickerProps, SecurityTokenPicker } from "../../common/securityTokenPicker/securityTokenPicker"; import "vott-react/dist/css/tagsInput.css"; +import Checkbox from "rc-checkbox"; import { IConnectionProviderPickerProps } from "../../common/connectionProviderPicker/connectionProviderPicker"; import LocalFolderPicker from "../../common/localFolderPicker/localFolderPicker"; @@ -54,6 +55,11 @@ export interface IProjectFormState { export default class ProjectForm extends React.Component { private widgets = { localFolderPicker: (LocalFolderPicker as any) as Widget, + checkbox: CustomWidget(Checkbox, (props) => ({ + checked: props.value, + onChange: (value) => props.onChange(value.target.checked), + disabled: props.disabled, + })), }; private tagsInput: React.RefObject; diff --git a/src/react/components/pages/projectSettings/projectForm.ui.json b/src/react/components/pages/projectSettings/projectForm.ui.json index abec223..03e76d2 100644 --- a/src/react/components/pages/projectSettings/projectForm.ui.json +++ b/src/react/components/pages/projectSettings/projectForm.ui.json @@ -2,6 +2,9 @@ "securityToken": { "ui:field": "securityToken" }, + "useSecurityToken": { + "ui:widget": "checkbox" + }, "description": { "ui:widget": "textarea" }, diff --git a/src/react/components/pages/projectSettings/projectSettingsPage.test.tsx b/src/react/components/pages/projectSettings/projectSettingsPage.test.tsx index c5b3777..1f4fdcb 100644 --- a/src/react/components/pages/projectSettings/projectSettingsPage.test.tsx +++ b/src/react/components/pages/projectSettings/projectSettingsPage.test.tsx @@ -60,6 +60,7 @@ describe("Project settings page", () => { const store = createReduxStore(MockFactory.initialState()); const props = MockFactory.projectSettingsProps(); const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject"); + const ensureSecurityTokenSpy = jest.spyOn(props.applicationActions, "ensureSecurityToken"); projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project)); @@ -68,6 +69,7 @@ describe("Project settings page", () => { await MockFactory.flushUi(); expect(saveProjectSpy).toBeCalled(); + expect(ensureSecurityTokenSpy).toBeCalled(); }); it("Throws an error when a user tries to create a duplicate project", async () => { @@ -114,6 +116,7 @@ describe("Project settings page", () => { const store = createReduxStore(initialState); const props = MockFactory.projectSettingsProps(); const saveProjectSpy = jest.spyOn(props.projectActions, "saveProject"); + const ensureSecurityTokenSpy = jest.spyOn(props.applicationActions, "ensureSecurityToken"); const saveAppSettingsSpy = jest.spyOn(props.applicationActions, "saveAppSettings"); projectServiceMock.prototype.save = jest.fn((project) => Promise.resolve(project)); @@ -133,6 +136,11 @@ describe("Project settings page", () => { securityToken: `${project.name} Token`, }); + expect(ensureSecurityTokenSpy).toBeCalledWith({ + ...project, + securityToken: `${project.name} Token`, + }); + expect(localStorage.removeItem).toBeCalledWith("projectForm"); }); diff --git a/src/react/components/pages/projectSettings/projectSettingsPage.tsx b/src/react/components/pages/projectSettings/projectSettingsPage.tsx index 68bdf43..76a9d4e 100644 --- a/src/react/components/pages/projectSettings/projectSettingsPage.tsx +++ b/src/react/components/pages/projectSettings/projectSettingsPage.tsx @@ -47,7 +47,7 @@ function mapDispatchToProps(dispatch) { }; } -const projectFormTempKey = "projectForm"; +const projectFormKey = "projectForm"; /** * @name - Project Settings Page @@ -64,14 +64,17 @@ export default class ProjectSettingsPage extends React.Component project.id === projectId); if (projectToLoad) { - await this.props.applicationActions.ensureSecurityToken(projectToLoad); + if (projectToLoad.useSecurityToken) { + await this.props.applicationActions.ensureSecurityToken(projectToLoad); + } + await this.props.projectActions.loadProject(projectToLoad); } } @@ -119,16 +122,19 @@ export default class ProjectSettingsPage extends React.Component { if (this.isPartialProject(project)) { - localStorage.setItem(projectFormTempKey, JSON.stringify(project)); + localStorage.setItem(projectFormKey, JSON.stringify(project)); } } private onFormSubmit = async (project: IProject) => { const isNew = !(!!project.id); - await this.props.applicationActions.ensureSecurityToken(project); + if (project.useSecurityToken) { + await this.props.applicationActions.ensureSecurityToken(project); + } + await this.props.projectActions.saveProject(project); - localStorage.removeItem(projectFormTempKey); + localStorage.removeItem(projectFormKey); toast.success(interpolate(strings.projectSettings.messages.saveSuccess, { project })); @@ -140,7 +146,7 @@ export default class ProjectSettingsPage extends React.Component { - localStorage.removeItem(projectFormTempKey); + localStorage.removeItem(projectFormKey); this.props.history.goBack(); } diff --git a/src/redux/actions/projectActions.ts b/src/redux/actions/projectActions.ts index f15575d..9fc367d 100644 --- a/src/redux/actions/projectActions.ts +++ b/src/redux/actions/projectActions.ts @@ -12,11 +12,9 @@ import { IProject, } from "../../models/applicationState"; import { createAction, createPayloadAction, IPayloadAction } from "./actionCreators"; -import { ExportAssetState, IExportResults } from "../../providers/export/exportProvider"; +import { IExportResults } from "../../providers/export/exportProvider"; import { appInfo } from "../../common/appInfo"; import { strings } from "../../common/strings"; -import { IExportFormat } from "vott-react"; -import { IVottJsonExportProviderOptions } from "../../providers/export/vottJson"; /** * Actions to be performed in relation to projects @@ -48,7 +46,7 @@ export function loadProject(project: IProject): const projectToken = appState.appSettings.securityTokens .find((securityToken) => securityToken.name === project.securityToken); - if (!projectToken) { + if (project.useSecurityToken && !projectToken) { throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found"); } const loadedProject = await projectService.load(project, projectToken); @@ -76,7 +74,7 @@ export function saveProject(project: IProject) const projectToken = appState.appSettings.securityTokens .find((securityToken) => securityToken.name === project.securityToken); - if (!projectToken) { + if (project.useSecurityToken && !projectToken) { throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found"); } @@ -104,7 +102,7 @@ export function deleteProject(project: IProject) const projectToken = appState.appSettings.securityTokens .find((securityToken) => securityToken.name === project.securityToken); - if (!projectToken) { + if (project.useSecurityToken && !projectToken) { throw new AppError(ErrorCode.SecurityTokenNotFound, "Security Token Not Found"); } diff --git a/src/services/importService.ts b/src/services/importService.ts index 80c5d25..b24703a 100644 --- a/src/services/importService.ts +++ b/src/services/importService.ts @@ -57,6 +57,7 @@ export default class ImportService implements IImportService { id: shortid.generate(), name: projectInfo.file.name.split(".")[0], version: packageJson.version, + useSecurityToken: true, securityToken: `${projectInfo.file.name.split(".")[0]} Token`, description: "Converted V1 Project", tags: parsedTags, diff --git a/src/services/projectService.test.ts b/src/services/projectService.test.ts index bec6f4f..cff7377 100644 --- a/src/services/projectService.test.ts +++ b/src/services/projectService.test.ts @@ -9,13 +9,13 @@ import { import { constants } from "../common/constants"; import { ExportProviderFactory } from "../providers/export/exportProviderFactory"; import { generateKey } from "../common/crypto"; -import { encryptProject, decryptProject } from "../common/utils"; +import * as utils from "../common/utils"; import { ExportAssetState } from "../providers/export/exportProvider"; import { IVottJsonExportProviderOptions } from "../providers/export/vottJson"; import { IPascalVOCExportProviderOptions } from "../providers/export/pascalVOC"; describe("Project Service", () => { - let projectSerivce: IProjectService = null; + let projectService: IProjectService = null; let testProject: IProject = null; let projectList: IProject[] = null; let securityToken: ISecurityToken = null; @@ -33,27 +33,41 @@ describe("Project Service", () => { StorageProviderFactory.create = jest.fn(() => storageProviderMock); ExportProviderFactory.create = jest.fn(() => exportProviderMock); + const encryptSpy = jest.spyOn(utils, "encryptProject"); + const decryptSpy = jest.spyOn(utils, "decryptProject"); + beforeEach(() => { securityToken = { name: "TestToken", key: generateKey(), }; testProject = MockFactory.createTestProject("TestProject"); - projectSerivce = new ProjectService(); + projectService = new ProjectService(); storageProviderMock.writeText.mockClear(); storageProviderMock.deleteFile.mockClear(); + encryptSpy.mockClear(); + decryptSpy.mockClear(); }); it("Load decrypts any project settings using the specified key", async () => { - const encryptedProject = encryptProject(testProject, securityToken); - const decryptedProject = await projectSerivce.load(encryptedProject, securityToken); + const encryptedProject = utils.encryptProject(testProject, securityToken); + const decryptedProject = await projectService.load(encryptedProject, securityToken); expect(decryptedProject).toEqual(testProject); + expect(decryptSpy).toBeCalledWith(encryptedProject, securityToken); + }); + + it("Does not decrypt project when a security token is not in use", async () => { + const project: IProject = { ...testProject, useSecurityToken: false }; + + const loadedProject = await projectService.load(project); + expect(loadedProject).toEqual(project); + expect(decryptSpy).not.toBeCalled(); }); it("Saves calls project storage provider to write project", async () => { - const result = await projectSerivce.save(testProject, securityToken); + const result = await projectService.save(testProject, securityToken); const encryptedProject: IProject = { ...testProject, @@ -72,6 +86,7 @@ describe("Project Service", () => { }; expect(result).toEqual(encryptedProject); + expect(encryptSpy).toBeCalledWith(testProject, securityToken); expect(StorageProviderFactory.create).toBeCalledWith( testProject.targetConnection.providerType, testProject.targetConnection.providerOptions, @@ -82,9 +97,16 @@ describe("Project Service", () => { expect.any(String)); }); + it("Does not encrypt project during save when a security token is not in use", async () => { + const projectToSave: IProject = { ...testProject, useSecurityToken: false }; + await projectService.save(projectToSave); + + expect(encryptSpy).not.toBeCalled(); + }); + it("sets default export settings when not defined", async () => { testProject.exportFormat = null; - const result = await projectSerivce.save(testProject, securityToken); + const result = await projectService.save(testProject, securityToken); const vottJsonExportProviderOptions: IVottJsonExportProviderOptions = { assetState: ExportAssetState.Visited, @@ -96,14 +118,14 @@ describe("Project Service", () => { providerOptions: vottJsonExportProviderOptions, }; - const decryptedProject = decryptProject(result, securityToken); + const decryptedProject = utils.decryptProject(result, securityToken); expect(decryptedProject.exportFormat).toEqual(expectedExportFormat); }); it("sets default active learning setting when not defined", async () => { testProject.activeLearningSettings = null; - const result = await projectSerivce.save(testProject, securityToken); + const result = await projectService.save(testProject, securityToken); const activeLearningSettings: IActiveLearningSettings = { autoDetect: false, @@ -116,7 +138,7 @@ describe("Project Service", () => { it("initializes tags to empty array if not defined", async () => { testProject.tags = null; - const result = await projectSerivce.save(testProject, securityToken); + const result = await projectService.save(testProject, securityToken); expect(result.tags).toEqual([]); }); @@ -127,7 +149,7 @@ describe("Project Service", () => { providerOptions: null, }; - await projectSerivce.save(testProject, securityToken); + await projectService.save(testProject, securityToken); expect(ExportProviderFactory.create).toBeCalledWith( testProject.exportFormat.providerType, @@ -140,7 +162,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, securityToken)).rejects.toEqual(expectedError); + await expect(projectService.save(testProject, securityToken)).rejects.toEqual(expectedError); }); it("Save throws error if storage provider cannot be created", async () => { @@ -148,11 +170,11 @@ describe("Project Service", () => { const createMock = StorageProviderFactory.create as jest.Mock; createMock.mockImplementationOnce(() => { throw expectedError; }); - await expect(projectSerivce.save(testProject, securityToken)).rejects.toEqual(expectedError); + await expect(projectService.save(testProject, securityToken)).rejects.toEqual(expectedError); }); it("Delete calls project storage provider to delete project", async () => { - await projectSerivce.delete(testProject); + await projectService.delete(testProject); expect(StorageProviderFactory.create).toBeCalledWith( testProject.targetConnection.providerType, @@ -167,7 +189,7 @@ describe("Project Service", () => { storageProviderMock.deleteFile .mockImplementationOnce(() => Promise.reject(expectedError)); - await expect(projectSerivce.delete(testProject)).rejects.toEqual(expectedError); + await expect(projectService.delete(testProject)).rejects.toEqual(expectedError); }); it("Delete call fails if storage provider cannot be created", async () => { @@ -175,20 +197,20 @@ describe("Project Service", () => { const createMock = StorageProviderFactory.create as jest.Mock; createMock.mockImplementationOnce(() => { throw expectedError; }); - await expect(projectSerivce.delete(testProject)).rejects.toEqual(expectedError); + await expect(projectService.delete(testProject)).rejects.toEqual(expectedError); }); it("isDuplicate returns false when called with a unique project", async () => { testProject = MockFactory.createTestProject("TestProject"); projectList = MockFactory.createTestProjects(); - expect(projectSerivce.isDuplicate(testProject, projectList)).toEqual(false); + expect(projectService.isDuplicate(testProject, projectList)).toEqual(false); }); it("isDuplicate returns true when called with a duplicate project", async () => { testProject = MockFactory.createTestProject("1"); testProject.id = undefined; projectList = MockFactory.createTestProjects(); - expect(projectSerivce.isDuplicate(testProject, projectList)).toEqual(true); + expect(projectService.isDuplicate(testProject, projectList)).toEqual(true); }); it("deletes all asset metadata files when project is deleted", async () => { @@ -199,7 +221,7 @@ describe("Project Service", () => { testProject.assets = _.keyBy(assets, (asset) => asset.id); - await projectSerivce.delete(testProject); + await projectService.delete(testProject); expect(storageProviderMock.deleteFile.mock.calls).toHaveLength(assets.length + 1); }); @@ -215,8 +237,8 @@ describe("Project Service", () => { } as IPascalVOCExportProviderOptions, }; - const encryptedProject = encryptProject(testProject, securityToken); - const decryptedProject = await projectSerivce.load(encryptedProject, securityToken); + const encryptedProject = utils.encryptProject(testProject, securityToken); + const decryptedProject = await projectService.load(encryptedProject, securityToken); expect(decryptedProject.exportFormat.providerType).toEqual("pascalVOC"); expect(decryptedProject.exportFormat.providerOptions).toEqual(testProject.exportFormat.providerOptions); diff --git a/src/services/projectService.ts b/src/services/projectService.ts index cf1c83a..c4c1223 100644 --- a/src/services/projectService.ts +++ b/src/services/projectService.ts @@ -19,8 +19,8 @@ import { IExportFormat } from "vott-react"; * @member delete - Delete a project */ export interface IProjectService { - load(project: IProject, securityToken: ISecurityToken): Promise; - save(project: IProject, securityToken: ISecurityToken): Promise; + load(project: IProject, securityToken?: ISecurityToken): Promise; + save(project: IProject, securityToken?: ISecurityToken): Promise; delete(project: IProject): Promise; isDuplicate(project: IProject, projectList: IProject[]): boolean; } @@ -49,11 +49,13 @@ export default class ProjectService implements IProjectService { * @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 { + public load(project: IProject, securityToken?: ISecurityToken): Promise { Guard.null(project); try { - const loadedProject = decryptProject(project, securityToken); + const loadedProject = project.useSecurityToken + ? decryptProject(project, securityToken) + : { ...project }; // Ensure tags is always initialized to an array if (!loadedProject.tags) { @@ -84,7 +86,7 @@ export default class ProjectService implements IProjectService { * @param project - Project to save * @param securityToken - Security Token to encrypt */ - public async save(project: IProject, securityToken: ISecurityToken): Promise { + public async save(project: IProject, securityToken?: ISecurityToken): Promise { Guard.null(project); if (!project.id) { @@ -110,7 +112,9 @@ export default class ProjectService implements IProjectService { const storageProvider = StorageProviderFactory.createFromConnection(project.targetConnection); await this.saveExportSettings(project); - project = encryptProject(project, securityToken); + project = project.useSecurityToken + ? encryptProject(project, securityToken) + : { ...project }; await storageProvider.writeText( `${project.name}${constants.projectFileExtension}`,