diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 74e1dba12b7..51e91dcbfb6 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -194,6 +194,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service" import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -211,6 +212,7 @@ import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; @@ -367,6 +369,7 @@ export default class MainBackground { apiService: ApiServiceAbstraction; hibpApiService: HibpApiService; environmentService: BrowserEnvironmentService; + cipherSdkService: CipherSdkService; cipherService: CipherServiceAbstraction; folderService: InternalFolderServiceAbstraction; userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction; @@ -973,6 +976,8 @@ export default class MainBackground { this.logService, ); + this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -988,7 +993,7 @@ export default class MainBackground { this.logService, this.cipherEncryptionService, this.messagingService, - this.sdkService, + this.cipherSdkService, ); this.folderService = new FolderService( this.keyService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 77b40b4268c..1cd7221ab70 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -147,11 +147,13 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service" import { UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; @@ -254,6 +256,7 @@ export class ServiceContainer { twoFactorApiService: TwoFactorApiService; hibpApiService: HibpApiService; environmentService: EnvironmentService; + cipherSdkService: CipherSdkService; cipherService: CipherService; folderService: InternalFolderService; organizationUserApiService: OrganizationUserApiService; @@ -794,6 +797,8 @@ export class ServiceContainer { this.logService, ); + this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -809,7 +814,7 @@ export class ServiceContainer { this.logService, this.cipherEncryptionService, this.messagingService, - this.sdkService, + this.cipherSdkService, ); this.cipherArchiveService = new DefaultCipherArchiveService( diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 2baa5410013..3b49b92cf99 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -303,6 +303,7 @@ import { import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -321,6 +322,7 @@ import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; @@ -590,6 +592,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultDomainSettingsService, deps: [StateProvider, PolicyServiceAbstraction, AccountService], }), + safeProvider({ + provide: CipherSdkService, + useClass: DefaultCipherSdkService, + deps: [SdkService, LogService], + }), safeProvider({ provide: CipherServiceAbstraction, useFactory: ( @@ -607,7 +614,7 @@ const safeProviders: SafeProvider[] = [ logService: LogService, cipherEncryptionService: CipherEncryptionService, messagingService: MessagingServiceAbstraction, - sdkService: SdkService, + cipherSdkService: CipherSdkService, ) => new CipherService( keyService, @@ -624,7 +631,7 @@ const safeProviders: SafeProvider[] = [ logService, cipherEncryptionService, messagingService, - sdkService, + cipherSdkService, ), deps: [ KeyService, @@ -641,7 +648,7 @@ const safeProviders: SafeProvider[] = [ LogService, CipherEncryptionService, MessagingServiceAbstraction, - SdkService, + CipherSdkService, ], }), safeProvider({ diff --git a/libs/common/src/vault/abstractions/cipher-sdk.service.ts b/libs/common/src/vault/abstractions/cipher-sdk.service.ts new file mode 100644 index 00000000000..3101531eda6 --- /dev/null +++ b/libs/common/src/vault/abstractions/cipher-sdk.service.ts @@ -0,0 +1,109 @@ +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +/** + * Service responsible for cipher operations using the SDK. + */ +export abstract class CipherSdkService { + /** + * Creates a new cipher on the server using the SDK. + * + * @param cipherView The cipher view to create + * @param userId The user ID to use for SDK client + * @param orgAdmin Whether this is an organization admin operation + * @returns A promise that resolves to the created cipher view + */ + abstract createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise; + + /** + * Updates a cipher on the server using the SDK. + * + * @param cipher The cipher view to update + * @param userId The user ID to use for SDK client + * @param originalCipherView The original cipher view before changes (optional, used for admin operations) + * @param orgAdmin Whether this is an organization admin operation + * @returns A promise that resolves to the updated cipher view + */ + abstract updateWithServer( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise; + + /** + * Deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is deleted + */ + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are deleted + */ + abstract deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Soft deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is soft deleted + */ + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Soft deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are soft deleted + */ + abstract softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Restores a soft-deleted cipher on the server using the SDK. + * + * @param id The cipher ID to restore + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is restored + */ + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Restores multiple soft-deleted ciphers on the server using the SDK. + * + * @param ids The cipher IDs to restore + * @param userId The user ID to use for SDK client + * @param orgId The organization ID (determines whether to use admin API) + * @returns A promise that resolves when the ciphers are restored + */ + abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; +} diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index 475fe9e23f3..1c7017d5d89 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -353,4 +353,366 @@ describe("CipherView", () => { }); }); }); + + // Note: These tests use jest.requireActual() because the file has jest.mock() calls + // at the top that mock LoginView, FieldView, etc. Those mocks are needed for other tests + // but interfere with these tests which need the real implementations. + describe("toSdkCreateCipherRequest", () => { + it("maps all properties correctly for a login cipher", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"]; + cipherView.name = "Test Login"; + cipherView.notes = "Test notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + + const field = new RealFieldView(); + field.name = "testField"; + field.value = "testValue"; + field.type = SdkFieldType.Text; + cipherView.fields = [field]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.collectionIds).toEqual([asUuid("b0473506-3c3c-4260-a734-dfaaf833ab6f")]); + expect(result.name).toBe("Test Login"); + expect(result.notes).toBe("Test notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + type: SdkFieldType.Text, + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("handles undefined organizationId and folderId", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.name).toBe("Test Cipher"); + }); + + it("handles empty collectionIds array", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.collectionIds = []; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.collectionIds).toEqual([]); + }); + + it("defaults favorite to false when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.favorite = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.favorite).toBe(false); + }); + + it("defaults reprompt to None when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.reprompt = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.reprompt).toBe(CipherRepromptType.None); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkCreateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("toSdkUpdateCipherRequest", () => { + it("maps all properties correctly for an update request", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.name = "Updated Login"; + cipherView.notes = "Updated notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + cipherView.archivedDate = new Date("2022-01-03T12:00:00.000Z"); + cipherView.key = new EncString("cipher-key"); + + const mockField = new RealFieldView(); + mockField.name = "testField"; + mockField.value = "testValue"; + cipherView.fields = [mockField]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.id).toEqual(asUuid("0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602")); + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.name).toBe("Updated Login"); + expect(result.notes).toBe("Updated notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.revisionDate).toBe("2022-01-02T12:00:00.000Z"); + expect(result.archivedDate).toBe("2022-01-03T12:00:00.000Z"); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + }); + expect(result.key).toBeDefined(); + }); + + it("handles undefined optional properties", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.archivedDate).toBeUndefined(); + expect(result.key).toBeUndefined(); + }); + + it("converts dates to ISO strings", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.revisionDate = new Date("2022-05-15T10:30:00.000Z"); + cipherView.archivedDate = new Date("2022-06-20T14:45:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.revisionDate).toBe("2022-05-15T10:30:00.000Z"); + expect(result.archivedDate).toBe("2022-06-20T14:45:00.000Z"); + }); + + it("includes attachments when present", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + const { AttachmentView: RealAttachmentView } = jest.requireActual("./attachment.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const attachment1 = new RealAttachmentView(); + attachment1.id = "attachment-id-1"; + attachment1.fileName = "file1.txt"; + + const attachment2 = new RealAttachmentView(); + attachment2.id = "attachment-id-2"; + attachment2.fileName = "file2.pdf"; + + cipherView.attachments = [attachment1, attachment2]; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.attachments).toHaveLength(2); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkUpdateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("getSdkCipherViewType", () => { + it("returns login type for Login cipher", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + expect((result as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("returns card type for Card cipher", () => { + const { CardView: RealCardView } = jest.requireActual("./card.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Card; + cipherView.card = new RealCardView(); + cipherView.card.cardholderName = "John Doe"; + cipherView.card.number = "4111111111111111"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("card"); + expect((result as any).card.cardholderName).toBe("John Doe"); + expect((result as any).card.number).toBe("4111111111111111"); + }); + + it("returns identity type for Identity cipher", () => { + const { IdentityView: RealIdentityView } = jest.requireActual("./identity.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Identity; + cipherView.identity = new RealIdentityView(); + cipherView.identity.firstName = "John"; + cipherView.identity.lastName = "Doe"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("identity"); + expect((result as any).identity.firstName).toBe("John"); + expect((result as any).identity.lastName).toBe("Doe"); + }); + + it("returns secureNote type for SecureNote cipher", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("secureNote"); + }); + + it("returns sshKey type for SshKey cipher", () => { + const { SshKeyView: RealSshKeyView } = jest.requireActual("./ssh-key.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SshKey; + cipherView.sshKey = new RealSshKeyView(); + cipherView.sshKey.privateKey = "privateKeyData"; + cipherView.sshKey.publicKey = "publicKeyData"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("sshKey"); + expect((result as any).sshKey.privateKey).toBe("privateKeyData"); + expect((result as any).sshKey.publicKey).toBe("publicKeyData"); + }); + + it("defaults to empty login for unknown cipher type", () => { + const cipherView = new CipherView(); + cipherView.type = 999 as CipherType; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher-sdk.service.spec.ts b/libs/common/src/vault/services/cipher-sdk.service.spec.ts new file mode 100644 index 00000000000..cb21ff28133 --- /dev/null +++ b/libs/common/src/vault/services/cipher-sdk.service.spec.ts @@ -0,0 +1,534 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { UserId, CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { CipherType } from "../enums/cipher-type"; + +import { DefaultCipherSdkService } from "./cipher-sdk.service"; + +describe("DefaultCipherSdkService", () => { + const sdkService = mock(); + const logService = mock(); + const userId = "test-user-id" as UserId; + const cipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId; + + let cipherSdkService: DefaultCipherSdkService; + let mockSdkClient: any; + let mockCiphersSdk: any; + let mockAdminSdk: any; + let mockVaultSdk: any; + + beforeEach(() => { + // Mock the SDK client chain for admin operations + mockAdminSdk = { + create: jest.fn(), + edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), + }; + mockCiphersSdk = { + create: jest.fn(), + edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), + admin: jest.fn().mockReturnValue(mockAdminSdk), + }; + mockVaultSdk = { + ciphers: jest.fn().mockReturnValue(mockCiphersSdk), + }; + const mockSdkValue = { + vault: jest.fn().mockReturnValue(mockVaultSdk), + }; + mockSdkClient = { + take: jest.fn().mockReturnValue({ + value: mockSdkValue, + [Symbol.dispose]: jest.fn(), + }), + }; + + // Mock sdkService to return the mock client + sdkService.userClient$.mockReturnValue(of(mockSdkClient)); + + cipherSdkService = new DefaultCipherSdkService(sdkService, logService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("createWithServer()", () => { + it("should create cipher using SDK when orgAdmin is false", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Test Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockCiphersSdk.create.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.createWithServer(cipherView, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: cipherView.name, + organizationId: expect.anything(), + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result?.name).toBe(cipherView.name); + }); + + it("should create cipher using SDK admin API when orgAdmin is true", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Test Admin Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.create.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.createWithServer(cipherView, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: cipherView.name, + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result?.name).toBe(cipherView.name); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to create cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + mockCiphersSdk.create.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to create cipher"), + ); + }); + }); + + describe("updateWithServer()", () => { + it("should update cipher using SDK when orgAdmin is false", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockCiphersSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should update cipher using SDK admin API when orgAdmin is true", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Admin Cipher"; + cipherView.organizationId = orgId; + + const originalCipherView = new CipherView(); + originalCipherView.id = cipherId; + originalCipherView.name = "Original Cipher"; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer( + cipherView, + userId, + originalCipherView, + true, + ); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + originalCipherView.toSdkCipherView(), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should update cipher using SDK admin API without originalCipherView", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Admin Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + expect.anything(), // Empty CipherView - timestamps vary so we just verify it was called + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + await expect( + cipherSdkService.updateWithServer(cipherView, userId, undefined, false), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to update cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + mockCiphersSdk.edit.mockRejectedValue(new Error("SDK error")); + + await expect( + cipherSdkService.updateWithServer(cipherView, userId, undefined, false), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to update cipher"), + ); + }); + }); + + describe("deleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + }); + + describe("deleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + }); + + describe("softDeleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should soft delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + }); + + describe("softDeleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should soft delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin soft delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow("SDK not available"); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete_many.mockRejectedValue(new Error("SDK error")); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + }); + + describe("restoreWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should restore cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + }); + + describe("restoreManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should restore multiple ciphers using SDK when orgId is not provided", async () => { + await cipherSdkService.restoreManyWithServer(testCipherIds, userId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore multiple ciphers using SDK admin API when orgId is provided", async () => { + const orgIdString = orgId as string; + await cipherSdkService.restoreManyWithServer(testCipherIds, userId, orgIdString); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore_many).toHaveBeenCalledWith(testCipherIds, orgIdString); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + }); +}); diff --git a/libs/common/src/vault/services/cipher-sdk.service.ts b/libs/common/src/vault/services/cipher-sdk.service.ts new file mode 100644 index 00000000000..9757b3d2cc7 --- /dev/null +++ b/libs/common/src/vault/services/cipher-sdk.service.ts @@ -0,0 +1,263 @@ +import { firstValueFrom, switchMap, catchError } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; + +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; + +export class DefaultCipherSdkService implements CipherSdkService { + constructor( + private sdkService: SdkService, + private logService: LogService, + ) {} + + async createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + const sdkCreateRequest = cipherView.toSdkCreateCipherRequest(); + let result: SdkCipherView; + if (orgAdmin) { + result = await ref.value.vault().ciphers().admin().create(sdkCreateRequest); + } else { + result = await ref.value.vault().ciphers().create(sdkCreateRequest); + } + return CipherView.fromSdkCipherView(result); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to create cipher: ${error}`); + throw error; + }), + ), + ); + } + + async updateWithServer( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + const sdkUpdateRequest = cipher.toSdkUpdateCipherRequest(); + let result: SdkCipherView; + if (orgAdmin) { + result = await ref.value + .vault() + .ciphers() + .admin() + .edit( + sdkUpdateRequest, + originalCipherView?.toSdkCipherView() || new CipherView().toSdkCipherView(), + ); + } else { + result = await ref.value.vault().ciphers().edit(sdkUpdateRequest); + } + return CipherView.fromSdkCipherView(result); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to update cipher: ${error}`); + throw error; + }), + ), + ); + } + + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().soft_delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().soft_delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin soft delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .soft_delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .soft_delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().restore(asUuid(id)); + } else { + await ref.value.vault().ciphers().restore(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore cipher: ${error}`); + throw error; + }), + ), + ); + } + + async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + + // No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable + // The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore + if (orgId) { + await ref.value + .vault() + .ciphers() + .admin() + .restore_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .restore_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } +} diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 1067361ddfb..0b1a7a1ad61 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -21,7 +21,6 @@ import { EncString } from "../../key-management/crypto/models/enc-string"; import { UriMatchStrategy } from "../../models/domain/domain-service"; import { ConfigService } from "../../platform/abstractions/config/config.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; import { Utils } from "../../platform/misc/utils"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -29,6 +28,7 @@ import { ContainerService } from "../../platform/services/container.service"; import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid"; import { CipherKey, OrgKey, UserKey } from "../../types/key"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; import { EncryptionContext } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { SearchService } from "../abstractions/search.service"; @@ -110,7 +110,7 @@ describe("Cipher Service", () => { const stateProvider = new FakeStateProvider(accountService); const cipherEncryptionService = mock(); const messageSender = mock(); - const sdkService = mock(); + const cipherSdkService = mock(); const userId = "TestUserId" as UserId; const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId; @@ -147,7 +147,7 @@ describe("Cipher Service", () => { logService, cipherEncryptionService, messageSender, - sdkService, + cipherSdkService, ); encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId }; @@ -210,48 +210,11 @@ describe("Cipher Service", () => { }); describe("createWithServer()", () => { - let mockSdkClient: any; - let mockCiphersSdk: any; - let mockAdminSdk: any; - let mockVaultSdk: any; - let sdkTestCipher: Cipher; - let sdkTestCipherData: CipherData; - beforeEach(() => { - // Mock encrypt to return encryptionContext for legacy path jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext); - // Mock decrypt to return cipherView for result jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => { return new CipherView(cipher); }); - - // Create cipher data with valid UUIDs for SDK tests - sdkTestCipherData = cipherData; - sdkTestCipher = new Cipher(sdkTestCipherData); - - // Mock the SDK client chain - define mockAdminSdk first before referencing it - mockAdminSdk = { - create: jest.fn(), - }; - mockCiphersSdk = { - create: jest.fn(), - admin: jest.fn().mockReturnValue(mockAdminSdk), - }; - mockVaultSdk = { - ciphers: jest.fn().mockReturnValue(mockCiphersSdk), - }; - const mockSdkValue = { - vault: jest.fn().mockReturnValue(mockVaultSdk), - }; - mockSdkClient = { - take: jest.fn().mockReturnValue({ - value: mockSdkValue, - [Symbol.dispose]: jest.fn(), - }), - }; - - // Mock sdkService to return the mock client - sdkService.userClient$.mockReturnValue(of(mockSdkClient)); }); it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => { @@ -267,7 +230,6 @@ describe("Cipher Service", () => { expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(expectedObj); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => { @@ -284,7 +246,6 @@ describe("Cipher Service", () => { expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(expectedObj); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should call apiService.postCipherCreate if collectionsIds != null", async () => { @@ -301,7 +262,6 @@ describe("Cipher Service", () => { expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(expectedObj); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => { @@ -317,103 +277,41 @@ describe("Cipher Service", () => { expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(expectedObj); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); - it("should use SDK to create cipher when feature flag is enabled", async () => { + it("should delegate to cipherSdkService when feature flag is enabled", async () => { configService.getFeatureFlag .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const cipherView = new CipherView(sdkTestCipher); - const mockSdkCipherView = cipherView.toSdkCipherView(); + const cipherView = new CipherView(encryptionContext.cipher); + const expectedResult = new CipherView(encryptionContext.cipher); - // Mock SDK create to return a cipher view - mockCiphersSdk.create.mockResolvedValue(mockSdkCipherView); + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "createWithServer") + .mockResolvedValue(expectedResult); + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); const apiSpy = jest.spyOn(apiService, "postCipher"); + const result = await cipherService.createWithServer(cipherView, userId); + expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined); expect(apiSpy).not.toHaveBeenCalled(); - expect(sdkService.userClient$).toHaveBeenCalledWith(userId); - expect(mockCiphersSdk.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: cipherView.name, - organizationId: expect.anything(), - }), - ); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); expect(result).toBeInstanceOf(CipherView); - expect(result.name).toBe(cipherView.name); - }); - - it("should use SDK to create admin cipher when feature flag is enabled and admin flag is passed", async () => { - configService.getFeatureFlag - .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(true); - - const cipherView = new CipherView(sdkTestCipher); - const mockSdkCipherView = cipherView.toSdkCipherView(); - - // Mock SDK admin create to return a cipher view - mockAdminSdk.create.mockResolvedValue(mockSdkCipherView); - - const apiSpy = jest.spyOn(apiService, "postCipherAdmin"); - const result = await cipherService.createWithServer(cipherView, userId, true); - - expect(apiSpy).not.toHaveBeenCalled(); - expect(sdkService.userClient$).toHaveBeenCalledWith(userId); - expect(mockCiphersSdk.admin).toHaveBeenCalled(); - expect(mockAdminSdk.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: cipherView.name, - }), - ); - expect(result).toBeInstanceOf(CipherView); - expect(result.name).toBe(cipherView.name); }); }); describe("updateWithServer()", () => { - let mockSdkClient: any; - let mockCiphersSdk: any; - let mockAdminSdk: any; - let mockVaultSdk: any; - beforeEach(() => { - // Mock encrypt to return encryptionContext for legacy path jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext); - // Mock decrypt to return cipherView for result jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => { return new CipherView(cipher); }); - // Mock upsert to return the cipher data jest.spyOn(cipherService, "upsert").mockResolvedValue({ [cipherData.id as CipherId]: cipherData, }); - - // Mock the SDK client chain for admin operations - mockAdminSdk = { - edit: jest.fn(), - }; - mockCiphersSdk = { - edit: jest.fn(), - admin: jest.fn().mockReturnValue(mockAdminSdk), - }; - mockVaultSdk = { - ciphers: jest.fn().mockReturnValue(mockCiphersSdk), - }; - const mockSdkValue = { - vault: jest.fn().mockReturnValue(mockVaultSdk), - }; - mockSdkClient = { - take: jest.fn().mockReturnValue({ - value: mockSdkValue, - [Symbol.dispose]: jest.fn(), - }), - }; - - // Mock sdkService to return the mock client - sdkService.userClient$.mockReturnValue(of(mockSdkClient)); }); it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => { @@ -421,7 +319,6 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(false); - // Create a fresh cipher with organizationId for this test const testCipher = new Cipher(cipherData); testCipher.organizationId = orgId; const testContext = { cipher: testCipher, encryptedFor: userId }; @@ -470,47 +367,47 @@ describe("Cipher Service", () => { expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj); }); - it("should use SDK to update cipher when feature flag is enabled", async () => { + it("should delegate to cipherSdkService when feature flag is enabled", async () => { configService.getFeatureFlag .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); const testCipher = new Cipher(cipherData); const cipherView = new CipherView(testCipher); - const mockSdkCipherView = cipherView.toSdkCipherView(); + const expectedResult = new CipherView(testCipher); - // Mock SDK edit to return a cipher view - mockCiphersSdk.edit.mockResolvedValue(mockSdkCipherView); + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "updateWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); + const apiSpy = jest.spyOn(apiService, "putCipher"); const result = await cipherService.updateWithServer(cipherView, userId); - expect(sdkService.userClient$).toHaveBeenCalledWith(userId); - expect(mockCiphersSdk.edit).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.anything(), - name: cipherView.name, - }), - ); + expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined, undefined); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); expect(result).toBeInstanceOf(CipherView); - expect(result.name).toBe(cipherView.name); }); - it("should use SDK admin API when orgAdmin is true", async () => { + it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => { configService.getFeatureFlag .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - // sdkTestCipherData already has a valid organizationId, use it directly const testCipher = new Cipher(cipherData); const cipherView = new CipherView(testCipher); const originalCipherView = new CipherView(testCipher); - const mockSdkCipherView = cipherView.toSdkCipherView(); + const expectedResult = new CipherView(testCipher); + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "updateWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); const apiSpy = jest.spyOn(apiService, "putCipherAdmin"); - // Mock SDK admin edit to return a cipher view - mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView); - const result = await cipherService.updateWithServer( cipherView, userId, @@ -518,16 +415,14 @@ describe("Cipher Service", () => { true, ); - expect(apiSpy).not.toHaveBeenCalled(); - expect(sdkService.userClient$).toHaveBeenCalledWith(userId); - expect(mockCiphersSdk.admin).toHaveBeenCalled(); - expect(mockAdminSdk.edit).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.anything(), - name: cipherView.name, - }), - originalCipherView.toSdkCipherView(), + expect(cipherSdkServiceSpy).toHaveBeenCalledWith( + cipherView, + userId, + originalCipherView, + true, ); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); expect(result).toBeInstanceOf(CipherView); }); }); @@ -1115,38 +1010,8 @@ describe("Cipher Service", () => { }); describe("deleteWithServer()", () => { - let mockSdkClient: any; - let mockCiphersSdk: any; - let mockAdminSdk: any; - let mockVaultSdk: any; const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; - beforeEach(() => { - // Mock the SDK client chain for delete operations - mockAdminSdk = { - delete: jest.fn().mockResolvedValue(undefined), - }; - mockCiphersSdk = { - delete: jest.fn().mockResolvedValue(undefined), - admin: jest.fn().mockReturnValue(mockAdminSdk), - }; - mockVaultSdk = { - ciphers: jest.fn().mockReturnValue(mockCiphersSdk), - }; - const mockSdkValue = { - vault: jest.fn().mockReturnValue(mockVaultSdk), - }; - mockSdkClient = { - take: jest.fn().mockReturnValue({ - value: mockSdkValue, - [Symbol.dispose]: jest.fn(), - }), - }; - - // Mock sdkService to return the mock client - sdkService.userClient$.mockReturnValue(of(mockSdkClient)); - }); - it("should call apiService.deleteCipher when feature flag is disabled", async () => { configService.getFeatureFlag .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) @@ -1157,7 +1022,6 @@ describe("Cipher Service", () => { await cipherService.deleteWithServer(testCipherId, userId); expect(apiSpy).toHaveBeenCalledWith(testCipherId); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { @@ -1170,7 +1034,6 @@ describe("Cipher Service", () => { await cipherService.deleteWithServer(testCipherId, userId, true); expect(apiSpy).toHaveBeenCalledWith(testCipherId); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should use SDK to delete cipher when feature flag is enabled", async () => { @@ -1178,14 +1041,14 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "deleteCipher"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); - await cipherService.deleteWithServer(testCipherId, userId); + await cipherService.deleteWithServer(testCipherId, userId, false); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); @@ -1194,54 +1057,24 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); await cipherService.deleteWithServer(testCipherId, userId, true); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); }); describe("deleteManyWithServer()", () => { - let mockSdkClient: any; - let mockCiphersSdk: any; - let mockAdminSdk: any; - let mockVaultSdk: any; const testCipherIds = [ "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, ]; - beforeEach(() => { - // Mock the SDK client chain for delete many operations - mockAdminSdk = { - delete_many: jest.fn().mockResolvedValue(undefined), - }; - mockCiphersSdk = { - delete_many: jest.fn().mockResolvedValue(undefined), - admin: jest.fn().mockReturnValue(mockAdminSdk), - }; - mockVaultSdk = { - ciphers: jest.fn().mockReturnValue(mockCiphersSdk), - }; - const mockSdkValue = { - vault: jest.fn().mockReturnValue(mockVaultSdk), - }; - mockSdkClient = { - take: jest.fn().mockReturnValue({ - value: mockSdkValue, - [Symbol.dispose]: jest.fn(), - }), - }; - - // Mock sdkService to return the mock client - sdkService.userClient$.mockReturnValue(of(mockSdkClient)); - }); - it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => { configService.getFeatureFlag .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) @@ -1252,7 +1085,6 @@ describe("Cipher Service", () => { await cipherService.deleteManyWithServer(testCipherIds, userId); expect(apiSpy).toHaveBeenCalled(); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { @@ -1265,7 +1097,6 @@ describe("Cipher Service", () => { await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); expect(apiSpy).toHaveBeenCalled(); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => { @@ -1273,14 +1104,14 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "deleteManyCiphers"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); - await cipherService.deleteManyWithServer(testCipherIds, userId); + await cipherService.deleteManyWithServer(testCipherIds, userId, false); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); @@ -1289,51 +1120,21 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); }); describe("softDeleteWithServer()", () => { - let mockSdkClient: any; - let mockCiphersSdk: any; - let mockAdminSdk: any; - let mockVaultSdk: any; const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; - beforeEach(() => { - // Mock the SDK client chain for soft delete operations - mockAdminSdk = { - soft_delete: jest.fn().mockResolvedValue(undefined), - }; - mockCiphersSdk = { - soft_delete: jest.fn().mockResolvedValue(undefined), - admin: jest.fn().mockReturnValue(mockAdminSdk), - }; - mockVaultSdk = { - ciphers: jest.fn().mockReturnValue(mockCiphersSdk), - }; - const mockSdkValue = { - vault: jest.fn().mockReturnValue(mockVaultSdk), - }; - mockSdkClient = { - take: jest.fn().mockReturnValue({ - value: mockSdkValue, - [Symbol.dispose]: jest.fn(), - }), - }; - - // Mock sdkService to return the mock client - sdkService.userClient$.mockReturnValue(of(mockSdkClient)); - }); - it("should call apiService.putDeleteCipher when feature flag is disabled", async () => { configService.getFeatureFlag .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) @@ -1344,7 +1145,6 @@ describe("Cipher Service", () => { await cipherService.softDeleteWithServer(testCipherId, userId); expect(apiSpy).toHaveBeenCalledWith(testCipherId); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { @@ -1357,7 +1157,6 @@ describe("Cipher Service", () => { await cipherService.softDeleteWithServer(testCipherId, userId, true); expect(apiSpy).toHaveBeenCalledWith(testCipherId); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should use SDK to soft delete cipher when feature flag is enabled", async () => { @@ -1365,14 +1164,14 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "putDeleteCipher"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); - await cipherService.softDeleteWithServer(testCipherId, userId); + await cipherService.softDeleteWithServer(testCipherId, userId, false); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); @@ -1381,54 +1180,24 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); await cipherService.softDeleteWithServer(testCipherId, userId, true); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); }); describe("softDeleteManyWithServer()", () => { - let mockSdkClient: any; - let mockCiphersSdk: any; - let mockAdminSdk: any; - let mockVaultSdk: any; const testCipherIds = [ "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, ]; - beforeEach(() => { - // Mock the SDK client chain for soft delete many operations - mockAdminSdk = { - soft_delete_many: jest.fn().mockResolvedValue(undefined), - }; - mockCiphersSdk = { - soft_delete_many: jest.fn().mockResolvedValue(undefined), - admin: jest.fn().mockReturnValue(mockAdminSdk), - }; - mockVaultSdk = { - ciphers: jest.fn().mockReturnValue(mockCiphersSdk), - }; - const mockSdkValue = { - vault: jest.fn().mockReturnValue(mockVaultSdk), - }; - mockSdkClient = { - take: jest.fn().mockReturnValue({ - value: mockSdkValue, - [Symbol.dispose]: jest.fn(), - }), - }; - - // Mock sdkService to return the mock client - sdkService.userClient$.mockReturnValue(of(mockSdkClient)); - }); - it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => { configService.getFeatureFlag .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) @@ -1439,7 +1208,6 @@ describe("Cipher Service", () => { await cipherService.softDeleteManyWithServer(testCipherIds, userId); expect(apiSpy).toHaveBeenCalled(); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { @@ -1454,7 +1222,6 @@ describe("Cipher Service", () => { await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); expect(apiSpy).toHaveBeenCalled(); - expect(mockSdkClient.take).not.toHaveBeenCalled(); }); it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => { @@ -1462,14 +1229,14 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); - await cipherService.softDeleteManyWithServer(testCipherIds, userId); + await cipherService.softDeleteManyWithServer(testCipherIds, userId, false); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); @@ -1478,14 +1245,14 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) .mockResolvedValue(true); - const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphersAdmin"); + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); - expect(mockSdkClient.take).toHaveBeenCalled(); - expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId); - expect(apiSpy).not.toHaveBeenCalled(); + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); expect(clearCacheSpy).toHaveBeenCalledWith(userId); }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 1f3303ef6c6..320d993dd97 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,9 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { - catchError, combineLatest, - EMPTY, filter, firstValueFrom, map, @@ -20,7 +18,7 @@ import { MessageSender } from "@bitwarden/common/platform/messaging"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; -import { CipherListView, CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { ApiService } from "../../abstractions/api.service"; import { AccountService } from "../../auth/abstractions/account.service"; @@ -35,7 +33,7 @@ import { ListResponse } from "../../models/response/list.response"; import { View } from "../../models/view/view"; import { ConfigService } from "../../platform/abstractions/config/config.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { asUuid, SdkService, uuidAsString } from "../../platform/abstractions/sdk/sdk.service"; +import { uuidAsString } from "../../platform/abstractions/sdk/sdk.service"; import { Utils } from "../../platform/misc/utils"; import Domain from "../../platform/models/domain/domain-base"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; @@ -45,6 +43,7 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid import { OrgKey, UserKey } from "../../types/key"; import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction, EncryptionContext, @@ -123,7 +122,7 @@ export class CipherService implements CipherServiceAbstraction { private logService: LogService, private cipherEncryptionService: CipherEncryptionService, private messageSender: MessageSender, - private sdkService: SdkService, + private cipherSdkService: CipherSdkService, ) {} localData$(userId: UserId): Observable> { @@ -1015,45 +1014,30 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, orgAdmin?: boolean, ): Promise { - const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag( + const useSdk = await this.configService.getFeatureFlag( FeatureFlag.PM27632_SdkCipherCrudOperations, ); - if (sdkCipherEncryptionEnabled) { - return (await this.createWithServer_sdk(cipherView, userId, orgAdmin)) || new CipherView(); - } else { - const encrypted = await this.encrypt(cipherView, userId); - const result = await this.createWithServer_legacy(encrypted, orgAdmin); - return await this.decrypt(result, userId); + if (useSdk) { + return ( + (await this.createWithServerUsingSdk(cipherView, userId, orgAdmin)) || new CipherView() + ); } + + const encrypted = await this.encrypt(cipherView, userId); + const result = await this.createWithServer_legacy(encrypted, orgAdmin); + return await this.decrypt(result, userId); } - private async createWithServer_sdk( + private async createWithServerUsingSdk( cipherView: CipherView, userId: UserId, orgAdmin?: boolean, ): Promise { - const resultCipherView = await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - const sdkCreateRequest = cipherView.toSdkCreateCipherRequest(); - let result: SdkCipherView; - if (orgAdmin) { - result = await ref.value.vault().ciphers().admin().create(sdkCreateRequest); - } else { - result = await ref.value.vault().ciphers().create(sdkCreateRequest); - } - return CipherView.fromSdkCipherView(result); - }), - catchError((error: unknown) => { - this.logService.error(`Failed to create cipher: ${error}`); - return EMPTY; - }), - ), + const resultCipherView = await this.cipherSdkService.createWithServer( + cipherView, + userId, + orgAdmin, ); await this.clearCache(userId); return resultCipherView; @@ -1091,51 +1075,31 @@ export class CipherService implements CipherServiceAbstraction { originalCipherView?: CipherView, orgAdmin?: boolean, ): Promise { - const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag( + const useSdk = await this.configService.getFeatureFlag( FeatureFlag.PM27632_SdkCipherCrudOperations, ); - if (sdkCipherEncryptionEnabled) { - return await this.updateWithServer_sdk(cipherView, userId, originalCipherView, orgAdmin); - } else { - const encrypted = await this.encrypt(cipherView, userId); - const updatedCipher = await this.updateWithServer_legacy(encrypted, orgAdmin); - const updatedCipherView = this.decrypt(updatedCipher, userId); - return updatedCipherView; + if (useSdk) { + return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin); } + + const encrypted = await this.encrypt(cipherView, userId); + const updatedCipher = await this.updateWithServer_legacy(encrypted, orgAdmin); + const updatedCipherView = await this.decrypt(updatedCipher, userId); + return updatedCipherView; } - async updateWithServer_sdk( + async updateWithServerUsingSdk( cipher: CipherView, userId: UserId, originalCipherView?: CipherView, orgAdmin?: boolean, ): Promise { - const resultCipherView = await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - const sdkUpdateRequest = cipher.toSdkUpdateCipherRequest(); - let result: SdkCipherView; - if (orgAdmin) { - result = await ref.value - .vault() - .ciphers() - .admin() - .edit(sdkUpdateRequest, originalCipherView?.toSdkCipherView()); - } else { - result = await ref.value.vault().ciphers().edit(sdkUpdateRequest); - } - return CipherView.fromSdkCipherView(result); - }), - catchError((error: unknown) => { - this.logService.error(`Failed to update cipher: ${error}`); - return EMPTY; - }), - ), + const resultCipherView = await this.cipherSdkService.updateWithServer( + cipher, + userId, + originalCipherView, + orgAdmin, ); await this.clearCache(userId); return resultCipherView; @@ -1535,7 +1499,7 @@ export class CipherService implements CipherServiceAbstraction { FeatureFlag.PM27632_SdkCipherCrudOperations, ); if (useSdk) { - return this.deleteWithServer_sdk(id, userId, asAdmin); + return this.deleteWithServerUsingSdk(id, userId, asAdmin); } if (asAdmin) { @@ -1547,26 +1511,12 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(id, userId); } - private async deleteWithServer_sdk(id: string, userId: UserId, asAdmin = false): Promise { - await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - if (asAdmin) { - await ref.value.vault().ciphers().admin().delete(asUuid(id)); - } else { - await ref.value.vault().ciphers().delete(asUuid(id)); - } - }), - catchError((error: unknown) => { - this.logService.error(`Failed to delete cipher: ${error}`); - return EMPTY; - }), - ), - ); + private async deleteWithServerUsingSdk( + id: string, + userId: UserId, + asAdmin = false, + ): Promise { + await this.cipherSdkService.deleteWithServer(id, userId, asAdmin); await this.clearCache(userId); } @@ -1580,7 +1530,7 @@ export class CipherService implements CipherServiceAbstraction { FeatureFlag.PM27632_SdkCipherCrudOperations, ); if (useSdk) { - return this.deleteManyWithServer_sdk(ids, userId, asAdmin, orgId); + return this.deleteManyWithServerUsingSdk(ids, userId, asAdmin, orgId); } const request = new CipherBulkDeleteRequest(ids); @@ -1592,44 +1542,13 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(ids, userId); } - private async deleteManyWithServer_sdk( + private async deleteManyWithServerUsingSdk( ids: string[], userId: UserId, asAdmin = false, orgId?: OrganizationId, ): Promise { - await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - if (asAdmin) { - if (orgId == null) { - throw new Error("Organization ID is required for admin delete."); - } - await ref.value - .vault() - .ciphers() - .admin() - .delete_many( - ids.map((id) => asUuid(id)), - asUuid(orgId), - ); - } else { - await ref.value - .vault() - .ciphers() - .delete_many(ids.map((id) => asUuid(id))); - } - }), - catchError((error: unknown) => { - this.logService.error(`Failed to delete multiple ciphers: ${error}`); - return EMPTY; - }), - ), - ); + await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId); await this.clearCache(userId); } @@ -1796,7 +1715,7 @@ export class CipherService implements CipherServiceAbstraction { FeatureFlag.PM27632_SdkCipherCrudOperations, ); if (useSdk) { - return this.softDeleteWithServer_sdk(id, userId, asAdmin); + return this.softDeleteWithServerUsingSdk(id, userId, asAdmin); } if (asAdmin) { @@ -1808,26 +1727,12 @@ export class CipherService implements CipherServiceAbstraction { await this.softDelete(id, userId); } - async softDeleteWithServer_sdk(id: string, userId: UserId, asAdmin = false): Promise { - await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - if (asAdmin) { - await ref.value.vault().ciphers().admin().soft_delete(asUuid(id)); - } else { - await ref.value.vault().ciphers().soft_delete(asUuid(id)); - } - }), - catchError((error: unknown) => { - this.logService.error(`Failed to soft delete cipher: ${error}`); - return EMPTY; - }), - ), - ); + private async softDeleteWithServerUsingSdk( + id: string, + userId: UserId, + asAdmin = false, + ): Promise { + await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin); await this.clearCache(userId); } @@ -1841,7 +1746,7 @@ export class CipherService implements CipherServiceAbstraction { FeatureFlag.PM27632_SdkCipherCrudOperations, ); if (useSdk) { - return this.softDeleteManyWithServer_sdk(ids, userId, asAdmin, orgId); + return this.softDeleteManyWithServerUsingSdk(ids, userId, asAdmin, orgId); } const request = new CipherBulkDeleteRequest(ids); @@ -1854,41 +1759,13 @@ export class CipherService implements CipherServiceAbstraction { await this.softDelete(ids, userId); } - async softDeleteManyWithServer_sdk( + private async softDeleteManyWithServerUsingSdk( ids: string[], userId: UserId, asAdmin = false, orgId?: OrganizationId, ): Promise { - await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - if (asAdmin) { - await ref.value - .vault() - .ciphers() - .admin() - .soft_delete_many( - ids.map((id) => asUuid(id)), - asUuid(orgId), - ); - } else { - await ref.value - .vault() - .ciphers() - .soft_delete_many(ids.map((id) => asUuid(id))); - } - }), - catchError((error: unknown) => { - this.logService.error(`Failed to soft delete multiple ciphers: ${error}`); - return EMPTY; - }), - ), - ); + await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId); await this.clearCache(userId); } @@ -1930,7 +1807,7 @@ export class CipherService implements CipherServiceAbstraction { FeatureFlag.PM27632_SdkCipherCrudOperations, ); if (useSdk) { - return await this.restoreWithServer_sdk(id, userId, asAdmin); + return await this.restoreWithServerUsingSdk(id, userId, asAdmin); } let response; @@ -1943,26 +1820,12 @@ export class CipherService implements CipherServiceAbstraction { await this.restore({ id: id, revisionDate: response.revisionDate }, userId); } - private async restoreWithServer_sdk(id: string, userId: UserId, asAdmin = false): Promise { - await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - if (asAdmin) { - await ref.value.vault().ciphers().admin().restore(asUuid(id)); - } else { - await ref.value.vault().ciphers().restore(asUuid(id)); - } - }), - catchError((error: unknown) => { - this.logService.error(`Failed to restore cipher: ${error}`); - return EMPTY; - }), - ), - ); + private async restoreWithServerUsingSdk( + id: string, + userId: UserId, + asAdmin = false, + ): Promise { + await this.cipherSdkService.restoreWithServer(id, userId, asAdmin); await this.clearCache(userId); } @@ -1975,7 +1838,7 @@ export class CipherService implements CipherServiceAbstraction { FeatureFlag.PM27632_SdkCipherCrudOperations, ); if (useSdk) { - return await this.restoreManyWithServer_sdk(ids, userId, orgId); + return await this.restoreManyWithServerUsingSdk(ids, userId, orgId); } let response; @@ -1995,43 +1858,12 @@ export class CipherService implements CipherServiceAbstraction { await this.restore(restores, userId); } - private async restoreManyWithServer_sdk( + private async restoreManyWithServerUsingSdk( ids: string[], userId: UserId, orgId?: string, ): Promise { - await firstValueFrom( - this.sdkService.userClient$(userId).pipe( - switchMap(async (sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - using ref = sdk.take(); - - // No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable - // The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore - if (orgId) { - await ref.value - .vault() - .ciphers() - .admin() - .restore_many( - ids.map((id) => asUuid(id)), - asUuid(orgId), - ); - } else { - await ref.value - .vault() - .ciphers() - .restore_many(ids.map((id) => asUuid(id))); - } - }), - catchError((error: unknown) => { - this.logService.error(`Failed to restore multiple ciphers: ${error}`); - return EMPTY; - }), - ), - ); + await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId); await this.clearCache(userId); }