diff --git a/libs/common/src/vault/abstractions/cipher-sdk.service.ts b/libs/common/src/vault/abstractions/cipher-sdk.service.ts index 1037bfc2b92..3101531eda6 100644 --- a/libs/common/src/vault/abstractions/cipher-sdk.service.ts +++ b/libs/common/src/vault/abstractions/cipher-sdk.service.ts @@ -1,4 +1,4 @@ -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; /** @@ -34,4 +34,76 @@ export abstract class CipherSdkService { 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/services/cipher-sdk.service.spec.ts b/libs/common/src/vault/services/cipher-sdk.service.spec.ts index 8e1a5b3747e..cb21ff28133 100644 --- a/libs/common/src/vault/services/cipher-sdk.service.spec.ts +++ b/libs/common/src/vault/services/cipher-sdk.service.spec.ts @@ -28,10 +28,22 @@ describe("DefaultCipherSdkService", () => { 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 = { @@ -210,7 +222,7 @@ describe("DefaultCipherSdkService", () => { id: expect.anything(), name: cipherView.name, }), - new CipherView().toSdkCipherView(), + expect.anything(), // Empty CipherView - timestamps vary so we just verify it was called ); expect(result).toBeInstanceOf(CipherView); expect(result.name).toBe(cipherView.name); @@ -243,4 +255,280 @@ describe("DefaultCipherSdkService", () => { ); }); }); + + 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 index 06f5d3eb961..9757b3d2cc7 100644 --- a/libs/common/src/vault/services/cipher-sdk.service.ts +++ b/libs/common/src/vault/services/cipher-sdk.service.ts @@ -1,8 +1,8 @@ import { firstValueFrom, switchMap, catchError } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { UserId } from "@bitwarden/common/types/guid"; +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"; @@ -79,4 +79,185 @@ export class DefaultCipherSdkService implements CipherSdkService { ), ); } + + 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 14b521df082..838e48aa366 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1010,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) @@ -1052,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 () => { @@ -1065,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 () => { @@ -1073,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); }); @@ -1089,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) @@ -1147,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 () => { @@ -1160,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 () => { @@ -1168,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); }); @@ -1184,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) @@ -1239,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 () => { @@ -1252,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 () => { @@ -1260,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); }); @@ -1276,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) @@ -1334,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 () => { @@ -1349,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 () => { @@ -1357,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); }); @@ -1373,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 393dca97ff8..ab394bc211e 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1394,7 +1394,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) { @@ -1406,26 +1406,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); } @@ -1439,7 +1425,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); @@ -1451,44 +1437,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); } @@ -1655,7 +1610,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) { @@ -1667,26 +1622,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); } @@ -1700,7 +1641,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); @@ -1713,41 +1654,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); } @@ -1789,7 +1702,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; @@ -1802,26 +1715,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); } @@ -1834,7 +1733,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; @@ -1854,43 +1753,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); }