From 0eed00be6f620422f492f7995a0b0ac0d93f6474 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 9 Jan 2026 15:30:23 -0800 Subject: [PATCH] Implement getAllDecrypted and getAllFromApiForOrganization for SDK --- .../src/vault/services/cipher.service.spec.ts | 77 ++++++++++++++++++- .../src/vault/services/cipher.service.ts | 50 +++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 4194a569b6b..1067361ddfb 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -46,7 +46,7 @@ import { CipherView } from "../models/view/cipher.view"; import { LoginUriView } from "../models/view/login-uri.view"; import { CipherService } from "./cipher.service"; -import { ENCRYPTED_CIPHERS } from "./key-state/ciphers.state"; +import { DECRYPTED_CIPHERS, ENCRYPTED_CIPHERS } from "./key-state/ciphers.state"; const ENCRYPTED_TEXT = "This data has been encrypted"; function encryptText(clearText: string | Uint8Array) { @@ -1600,6 +1600,81 @@ describe("Cipher Service", () => { }); }); + describe("getAllDecrypted()", () => { + let mockSdkClient: any; + let mockCiphersSdk: any; + let mockVaultSdk: any; + const mockSdkCipherView1 = { + id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22", + name: "Test Cipher 1", + }; + const mockSdkCipherView2 = { + id: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23", + name: "Test Cipher 2", + }; + + beforeEach(() => { + // Mock the SDK client chain for list + mockCiphersSdk = { + list: jest.fn().mockResolvedValue({ + successes: [mockSdkCipherView1, mockSdkCipherView2], + failures: [], + }), + }; + 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)); + + // Clear the decrypted cache to ensure we test the decrypt path + stateProvider.singleUser.getFake(mockUserId, DECRYPTED_CIPHERS).nextState({}); + }); + + it("should use SDK to list and decrypt ciphers when feature flag is enabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(true); + + const result = await cipherService.getAllDecrypted(mockUserId); + + expect(mockSdkClient.take).toHaveBeenCalled(); + expect(mockCiphersSdk.list).toHaveBeenCalled(); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(CipherView); + expect(result[1]).toBeInstanceOf(CipherView); + }); + + it("should not call SDK when feature flag is disabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); + + // Just verify SDK is not called - don't test the full legacy path + // as it would require complex mocking of keyService observables + stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).nextState({}); + + try { + await cipherService.getAllDecrypted(mockUserId); + } catch { + // Expected to fail due to missing keyService mocks, but that's okay + // We just want to verify SDK wasn't called + } + + expect(mockSdkClient.take).not.toHaveBeenCalled(); + }); + }); + describe("replace (no upsert)", () => { // In order to set up initial state we need to manually update the encrypted state // which will result in an emission. All tests will have this baseline emission. diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index a61890ce810..f2eecf08fdc 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -8,6 +8,7 @@ import { firstValueFrom, map, Observable, + of, Subject, switchMap, tap, @@ -27,7 +28,7 @@ import { AutofillSettingsServiceAbstraction } from "../../autofill/services/auto import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; import { FeatureFlag } from "../../enums/feature-flag.enum"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "../../key-management/crypto/models/enc-string"; +import { DECRYPT_ERROR, EncString } from "../../key-management/crypto/models/enc-string"; import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { ErrorResponse } from "../../models/response/error.response"; import { ListResponse } from "../../models/response/list.response"; @@ -468,6 +469,13 @@ export class CipherService implements CipherServiceAbstraction { * @deprecated Use `cipherViews$` observable instead */ async getAllDecrypted(userId: UserId): Promise { + const useSdk = await this.configService.getFeatureFlag( + FeatureFlag.PM27632_SdkCipherCrudOperations, + ); + if (useSdk) { + return this.getAllDecrypted_sdk(userId); + } + const decCiphers = await this.getDecryptedCiphers(userId); if (decCiphers != null && decCiphers.length !== 0) { await this.reindexCiphers(userId); @@ -489,6 +497,44 @@ export class CipherService implements CipherServiceAbstraction { return newDecCiphers; } + private async getAllDecrypted_sdk(userId: UserId): Promise { + // Use SDK to list and decrypt all ciphers from state + const result = await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + + const decryptResult = await ref.value.vault().ciphers().list(); + + const successViews = decryptResult.successes.map((sdkCipherView: any) => + CipherView.fromSdkCipherView(sdkCipherView), + ); + const failureViews: CipherView[] = decryptResult.failures.map((failure) => { + const cipher = Cipher.fromSdkCipher(failure); + const cipherView = new CipherView(cipher); + cipherView.name = DECRYPT_ERROR; + cipherView.decryptionFailure = true; + return cipherView; + }); + + await this.setDecryptedCipherCache(successViews, userId); + await this.setFailedDecryptedCiphers(failureViews, userId); + + return successViews; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to list and decrypt ciphers: ${error}`); + return of([]); + }), + ), + ); + + return result; + } + private async getDecryptedCiphers(userId: UserId) { return Object.values( await firstValueFrom(this.decryptedCiphersState(userId).state$.pipe(map((c) => c ?? {}))), @@ -771,7 +817,7 @@ export class CipherService implements CipherServiceAbstraction { }), catchError((error: unknown) => { this.logService.error(`Failed to list organization ciphers: ${error}`); - return []; + return of([]); }), ), );