diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 53dc3b48df..5dd37c42ef 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -20,8 +20,6 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { CipherCreateRequest } from "@bitwarden/common/vault/models/request/cipher-create.request"; -import { CipherRequest } from "@bitwarden/common/vault/models/request/cipher.request"; import { AddEditComponent as BaseAddEditComponent } from "../individual-vault/add-edit.component"; @@ -115,19 +113,6 @@ export class AddEditComponent extends BaseAddEditComponent { return this.cipherService.encrypt(this.cipher, null, this.originalCipher); } - protected async saveCipher(cipher: Cipher) { - if (!this.organization.canEditAnyCollection || cipher.organizationId == null) { - return super.saveCipher(cipher); - } - if (this.editMode && !this.cloneMode) { - const request = new CipherRequest(cipher); - return this.apiService.putCipherAdmin(this.cipherId, request); - } else { - const request = new CipherCreateRequest(cipher); - return this.apiService.postCipherAdmin(request); - } - } - protected async deleteCipher() { if (!this.organization.canEditAnyCollection) { return super.deleteCipher(); diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 08d6a23076..52780ae395 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -600,9 +600,11 @@ export class AddEditComponent implements OnInit, OnDestroy { } protected saveCipher(cipher: Cipher) { + const isNotClone = this.editMode && !this.cloneMode; + const orgAdmin = this.organization?.isAdmin; return this.cipher.id == null - ? this.cipherService.createWithServer(cipher) - : this.cipherService.updateWithServer(cipher); + ? this.cipherService.createWithServer(cipher, orgAdmin) + : this.cipherService.updateWithServer(cipher, orgAdmin, isNotClone); } protected deleteCipher() { diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index edb4ab6460..e328c5bd49 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -33,8 +33,8 @@ export abstract class CipherService { updateLastUsedDate: (id: string) => Promise; updateLastLaunchedDate: (id: string) => Promise; saveNeverDomain: (domain: string) => Promise; - createWithServer: (cipher: Cipher) => Promise; - updateWithServer: (cipher: Cipher) => Promise; + createWithServer: (cipher: Cipher, orgAdmin?: boolean) => Promise; + updateWithServer: (cipher: Cipher, orgAdmin?: boolean, isNotClone?: boolean) => Promise; shareWithServer: ( cipher: CipherView, organizationId: string, diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index d7f96b9ae8..d6e631cfae 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,48 +1,106 @@ // eslint-disable-next-line no-restricted-imports -import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; +import { mock, mockReset } from "jest-mock-extended"; import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; import { SettingsService } from "../../abstractions/settings.service"; +import { UriMatchType, FieldType } from "../../enums"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; -import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; -import { EncString } from "../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; +import { CipherRepromptType } from "../enums/cipher-reprompt-type"; +import { CipherType } from "../enums/cipher-type"; +import { CipherData } from "../models/data/cipher.data"; import { Cipher } from "../models/domain/cipher"; +import { CipherCreateRequest } from "../models/request/cipher-create.request"; +import { CipherPartialRequest } from "../models/request/cipher-partial.request"; +import { CipherRequest } from "../models/request/cipher.request"; import { CipherService } from "./cipher.service"; -const ENCRYPTED_TEXT = "This data has been encrypted"; -const ENCRYPTED_BYTES = Substitute.for(); +const cipherData: CipherData = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + edit: true, + viewPassword: true, + organizationUseTotp: true, + favorite: false, + revisionDate: "2022-01-31T12:00:00.000Z", + type: CipherType.Login, + name: "EncryptedString", + notes: "EncryptedString", + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: null, + reprompt: CipherRepromptType.None, + login: { + uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }], + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + totp: "EncryptedString", + autofillOnPageLoad: false, + }, + passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }], + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Text, + linkedId: null, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Hidden, + linkedId: null, + }, + ], +}; describe("Cipher Service", () => { - let cryptoService: SubstituteOf; - let stateService: SubstituteOf; - let settingsService: SubstituteOf; - let apiService: SubstituteOf; - let cipherFileUploadService: SubstituteOf; - let i18nService: SubstituteOf; - let searchService: SubstituteOf; - let encryptService: SubstituteOf; + const cryptoService = mock(); + const stateService = mock(); + const settingsService = mock(); + const apiService = mock(); + const cipherFileUploadService = mock(); + const i18nService = mock(); + const searchService = mock(); + const encryptService = mock(); let cipherService: CipherService; + let cipherObj: Cipher; beforeEach(() => { - cryptoService = Substitute.for(); - stateService = Substitute.for(); - settingsService = Substitute.for(); - apiService = Substitute.for(); - cipherFileUploadService = Substitute.for(); - i18nService = Substitute.for(); - searchService = Substitute.for(); - encryptService = Substitute.for(); - - cryptoService.encryptToBytes(Arg.any(), Arg.any()).resolves(ENCRYPTED_BYTES); - cryptoService.encrypt(Arg.any(), Arg.any()).resolves(new EncString(ENCRYPTED_TEXT)); + mockReset(apiService); + mockReset(cryptoService); + mockReset(stateService); + mockReset(settingsService); + mockReset(cipherFileUploadService); + mockReset(i18nService); + mockReset(searchService); + mockReset(encryptService); cipherService = new CipherService( cryptoService, @@ -54,17 +112,97 @@ describe("Cipher Service", () => { encryptService, cipherFileUploadService ); + + cipherObj = new Cipher(cipherData); + }); + describe("saveAttachmentRawWithServer()", () => { + it("should upload encrypted file contents with save attachments", async () => { + const fileName = "filename"; + const fileData = new Uint8Array(10).buffer; + cryptoService.getOrgKey.mockReturnValue( + Promise.resolve(new SymmetricCryptoKey(new Uint8Array(32))) + ); + cryptoService.makeEncKey.mockReturnValue( + Promise.resolve(new SymmetricCryptoKey(new Uint8Array(32))) + ); + const spy = jest.spyOn(cipherFileUploadService, "upload"); + + await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData); + + expect(spy).toHaveBeenCalled(); + }); }); - it("attachments upload encrypted file contents", async () => { - const fileName = "filename"; - const fileData = new Uint8Array(10).buffer; - cryptoService.getOrgKey(Arg.any()).resolves(new SymmetricCryptoKey(new Uint8Array(32))); + describe("createWithServer()", () => { + it("should call apiService.postCipherAdmin when orgAdmin param is true", async () => { + const spy = jest + .spyOn(apiService, "postCipherAdmin") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.createWithServer(cipherObj, true); + const expectedObj = new CipherCreateRequest(cipherObj); - await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData); + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expectedObj); + }); - cipherFileUploadService - .received(1) - .upload(Arg.any(), Arg.any(), ENCRYPTED_BYTES, Arg.any(), Arg.any()); + it("should call apiService.postCipherCreate if collectionsIds != null", async () => { + cipherObj.collectionIds = ["123"]; + const spy = jest + .spyOn(apiService, "postCipherCreate") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.createWithServer(cipherObj); + const expectedObj = new CipherCreateRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expectedObj); + }); + + it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => { + const spy = jest + .spyOn(apiService, "postCipher") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.createWithServer(cipherObj); + const expectedObj = new CipherRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expectedObj); + }); + }); + + describe("updateWithServer()", () => { + it("should call apiService.putCipherAdmin when orgAdmin and isNotClone params are true", async () => { + const spy = jest + .spyOn(apiService, "putCipherAdmin") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.updateWithServer(cipherObj, true, true); + const expectedObj = new CipherRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj); + }); + + it("should call apiService.putCipher if cipher.edit is true", async () => { + cipherObj.edit = true; + const spy = jest + .spyOn(apiService, "putCipher") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.updateWithServer(cipherObj); + const expectedObj = new CipherRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj); + }); + + it("should call apiService.putPartialCipher when orgAdmin, isNotClone, and edit are false", async () => { + cipherObj.edit = false; + const spy = jest + .spyOn(apiService, "putPartialCipher") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.updateWithServer(cipherObj); + const expectedObj = new CipherPartialRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj); + }); }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 9828d1e93f..3da3752702 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -519,9 +519,12 @@ export class CipherService implements CipherServiceAbstraction { await this.stateService.setNeverDomains(domains); } - async createWithServer(cipher: Cipher): Promise { + async createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise { let response: CipherResponse; - if (cipher.collectionIds != null) { + if (orgAdmin) { + const request = new CipherCreateRequest(cipher); + response = await this.apiService.postCipherAdmin(request); + } else if (cipher.collectionIds != null) { const request = new CipherCreateRequest(cipher); response = await this.apiService.postCipherCreate(request); } else { @@ -534,9 +537,12 @@ export class CipherService implements CipherServiceAbstraction { await this.upsert(data); } - async updateWithServer(cipher: Cipher): Promise { + async updateWithServer(cipher: Cipher, orgAdmin?: boolean, isNotClone?: boolean): Promise { let response: CipherResponse; - if (cipher.edit) { + if (orgAdmin && isNotClone) { + const request = new CipherRequest(cipher); + response = await this.apiService.putCipherAdmin(cipher.id, request); + } else if (cipher.edit) { const request = new CipherRequest(cipher); response = await this.apiService.putCipher(cipher.id, request); } else {