1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-28 15:23:53 +00:00

Move getCipher functions to cipher-sdk.service.ts when using SDK flag

This commit is contained in:
Nik Gilmore
2026-01-21 17:20:01 -08:00
parent 86e668ba35
commit c5975f02e6
5 changed files with 301 additions and 166 deletions

View File

@@ -1,6 +1,16 @@
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
/**
* Result of decrypting all ciphers, containing both successes and failures.
*/
export interface DecryptAllCiphersResult {
/** Successfully decrypted cipher views */
successes: CipherView[];
/** Cipher views that failed to decrypt (with decryptionFailure flag set) */
failures: CipherView[];
}
/**
* Service responsible for cipher operations using the SDK.
*/
@@ -106,4 +116,26 @@ export abstract class CipherSdkService {
* @returns A promise that resolves when the ciphers are restored
*/
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
/**
* Lists and decrypts all ciphers from state using the SDK.
*
* @param userId The user ID to use for SDK client
* @returns A promise that resolves to the decrypt result containing successes and failures
*/
abstract getAllDecrypted(userId: UserId): Promise<DecryptAllCiphersResult>;
/**
* Fetches and decrypts all ciphers for an organization from the API using the SDK.
*
* @param organizationId The organization ID to fetch ciphers for
* @param userId The user ID to use for SDK client
* @param includeMemberItems Whether to include member items
* @returns A promise that resolves to the decrypted cipher views
*/
abstract getAllFromApiForOrganization(
organizationId: string,
userId: UserId,
includeMemberItems: boolean,
): Promise<CipherView[]>;
}

View File

@@ -34,6 +34,7 @@ describe("DefaultCipherSdkService", () => {
soft_delete_many: jest.fn().mockResolvedValue(undefined),
restore: jest.fn().mockResolvedValue(undefined),
restore_many: jest.fn().mockResolvedValue(undefined),
list_org_ciphers: jest.fn().mockResolvedValue({ successes: [], failures: [] }),
};
mockCiphersSdk = {
create: jest.fn(),
@@ -44,6 +45,7 @@ describe("DefaultCipherSdkService", () => {
soft_delete_many: jest.fn().mockResolvedValue(undefined),
restore: jest.fn().mockResolvedValue(undefined),
restore_many: jest.fn().mockResolvedValue(undefined),
list: jest.fn().mockResolvedValue({ successes: [], failures: [] }),
admin: jest.fn().mockReturnValue(mockAdminSdk),
};
mockVaultSdk = {
@@ -531,4 +533,135 @@ describe("DefaultCipherSdkService", () => {
);
});
});
describe("getAllDecrypted()", () => {
it("should list and decrypt ciphers using SDK", async () => {
const mockSdkCipherView = new CipherView().toSdkCipherView();
mockSdkCipherView.name = "Test Cipher";
mockCiphersSdk.list.mockResolvedValue({
successes: [mockSdkCipherView],
failures: [],
});
const result = await cipherSdkService.getAllDecrypted(userId);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.list).toHaveBeenCalled();
expect(result.successes).toHaveLength(1);
expect(result.successes[0]).toBeInstanceOf(CipherView);
expect(result.failures).toHaveLength(0);
});
it("should return failures with decryptionFailure flag set", async () => {
// Create a minimal mock that matches what fromSdkCipher expects
const mockFailedCipher: any = {
id: cipherId,
name: "2.encryptedName|iv|data",
type: CipherType.Login,
organizationId: null,
folderId: null,
favorite: false,
edit: true,
viewPassword: true,
organizationUseTotp: false,
revisionDate: new Date().toISOString(),
collectionIds: [],
deletedDate: null,
reprompt: 0,
key: null,
localData: null,
attachments: null,
fields: null,
passwordHistory: null,
creationDate: new Date().toISOString(),
login: null,
secureNote: null,
card: null,
identity: null,
sshKey: null,
};
mockCiphersSdk.list.mockResolvedValue({
successes: [],
failures: [mockFailedCipher],
});
const result = await cipherSdkService.getAllDecrypted(userId);
expect(result.successes).toHaveLength(0);
expect(result.failures).toHaveLength(1);
expect(result.failures[0].decryptionFailure).toBe(true);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(cipherSdkService.getAllDecrypted(userId)).rejects.toThrow("SDK not available");
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to list and decrypt ciphers"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockCiphersSdk.list.mockRejectedValue(new Error("SDK error"));
await expect(cipherSdkService.getAllDecrypted(userId)).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to list and decrypt ciphers"),
);
});
});
describe("getAllFromApiForOrganization()", () => {
it("should list organization ciphers using SDK admin API", async () => {
const mockSdkCipherView = new CipherView().toSdkCipherView();
mockSdkCipherView.name = "Org Cipher";
mockAdminSdk.list_org_ciphers.mockResolvedValue({
successes: [mockSdkCipherView],
failures: [],
});
const result = await cipherSdkService.getAllFromApiForOrganization(orgId, userId, false);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.admin).toHaveBeenCalled();
expect(mockAdminSdk.list_org_ciphers).toHaveBeenCalledWith(orgId, false);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(CipherView);
});
it("should pass includeMemberItems parameter to SDK", async () => {
mockAdminSdk.list_org_ciphers.mockResolvedValue({
successes: [],
failures: [],
});
await cipherSdkService.getAllFromApiForOrganization(orgId, userId, true);
expect(mockAdminSdk.list_org_ciphers).toHaveBeenCalledWith(orgId, true);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
await expect(
cipherSdkService.getAllFromApiForOrganization(orgId, userId, false),
).rejects.toThrow("SDK not available");
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to list organization ciphers"),
);
});
it("should throw error and log when SDK throws an error", async () => {
mockAdminSdk.list_org_ciphers.mockRejectedValue(new Error("SDK error"));
await expect(
cipherSdkService.getAllFromApiForOrganization(orgId, userId, false),
).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to list organization ciphers"),
);
});
});
});

View File

@@ -1,12 +1,14 @@
import { firstValueFrom, switchMap, catchError } from "rxjs";
import { DECRYPT_ERROR } from "@bitwarden/common/key-management/crypto/models/enc-string";
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";
import { CipherSdkService, DecryptAllCiphersResult } from "../abstractions/cipher-sdk.service";
import { Cipher } from "../models/domain/cipher";
export class DefaultCipherSdkService implements CipherSdkService {
constructor(
@@ -260,4 +262,78 @@ export class DefaultCipherSdkService implements CipherSdkService {
),
);
}
async getAllDecrypted(userId: UserId): Promise<DecryptAllCiphersResult> {
return 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();
// Convert successes - SDK returns array of SdkCipherView
const successArray = Array.isArray(decryptResult.successes)
? decryptResult.successes
: Array.from(decryptResult.successes ?? []);
const successes = successArray
.map((sdkCipherView: any) => CipherView.fromSdkCipherView(sdkCipherView))
.filter((v): v is CipherView => v !== undefined);
// Convert failures to CipherView with error markers
const failureArray = Array.isArray(decryptResult.failures)
? decryptResult.failures
: Array.from(decryptResult.failures ?? []);
const failures: CipherView[] = failureArray.map((failure: any) => {
const cipher = Cipher.fromSdkCipher(failure);
const cipherView = new CipherView(cipher);
cipherView.name = DECRYPT_ERROR;
cipherView.decryptionFailure = true;
return cipherView;
});
return { successes, failures };
}),
catchError((error: unknown) => {
this.logService.error(`Failed to list and decrypt ciphers: ${error}`);
throw error;
}),
),
);
}
async getAllFromApiForOrganization(
organizationId: string,
userId: UserId,
includeMemberItems: boolean,
): Promise<CipherView[]> {
return 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()
.admin()
.list_org_ciphers(asUuid(organizationId), includeMemberItems);
return decryptResult.successes
.map((sdkCipherView: any) => CipherView.fromSdkCipherView(sdkCipherView))
.filter((v): v is CipherView => v !== undefined);
}),
catchError((error: unknown) => {
this.logService.error(`Failed to list organization ciphers: ${error}`);
throw error;
}),
),
);
}
}

View File

@@ -1258,47 +1258,7 @@ describe("Cipher Service", () => {
});
describe("getAllFromApiForOrganization()", () => {
let mockSdkClient: any;
let mockCiphersSdk: any;
let mockAdminSdk: any;
let mockVaultSdk: any;
const testOrgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;
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_org_ciphers
mockAdminSdk = {
list_org_ciphers: jest.fn().mockResolvedValue({
successes: [mockSdkCipherView1, mockSdkCipherView2],
failures: [],
}),
};
mockCiphersSdk = {
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.getCiphersOrganization when feature flag is disabled", async () => {
configService.getFeatureFlag
@@ -1316,7 +1276,6 @@ describe("Cipher Service", () => {
await cipherService.getAllFromApiForOrganization(testOrgId, true);
expect(apiSpy).toHaveBeenCalledWith(testOrgId, true);
expect(mockSdkClient.take).not.toHaveBeenCalled();
});
it("should call apiService.getCiphersOrganization without includeMemberItems when not provided", async () => {
@@ -1332,7 +1291,6 @@ describe("Cipher Service", () => {
await cipherService.getAllFromApiForOrganization(testOrgId);
expect(apiSpy).toHaveBeenCalledWith(testOrgId, undefined);
expect(mockSdkClient.take).not.toHaveBeenCalled();
});
it("should use SDK to list organization ciphers when feature flag is enabled", async () => {
@@ -1340,12 +1298,20 @@ describe("Cipher Service", () => {
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(true);
const mockCipherView1 = new CipherView();
mockCipherView1.name = "Test Cipher 1";
const mockCipherView2 = new CipherView();
mockCipherView2.name = "Test Cipher 2";
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "getAllFromApiForOrganization")
.mockResolvedValue([mockCipherView1, mockCipherView2]);
const apiSpy = jest.spyOn(apiService, "getCiphersOrganization");
const result = await cipherService.getAllFromApiForOrganization(testOrgId, true);
expect(mockSdkClient.take).toHaveBeenCalled();
expect(mockAdminSdk.list_org_ciphers).toHaveBeenCalledWith(testOrgId, true);
expect(sdkServiceSpy).toHaveBeenCalledWith(testOrgId, mockUserId, true);
expect(apiSpy).not.toHaveBeenCalled();
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(CipherView);
@@ -1357,53 +1323,21 @@ describe("Cipher Service", () => {
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(true);
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "getAllFromApiForOrganization")
.mockResolvedValue([]);
const apiSpy = jest.spyOn(apiService, "getCiphersOrganization");
await cipherService.getAllFromApiForOrganization(testOrgId);
expect(mockSdkClient.take).toHaveBeenCalled();
expect(mockAdminSdk.list_org_ciphers).toHaveBeenCalledWith(testOrgId, false);
expect(sdkServiceSpy).toHaveBeenCalledWith(testOrgId, mockUserId, false);
expect(apiSpy).not.toHaveBeenCalled();
});
});
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({});
});
@@ -1413,21 +1347,32 @@ describe("Cipher Service", () => {
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
.mockResolvedValue(true);
const mockCipherView1 = new CipherView();
mockCipherView1.name = "Test Cipher 1";
const mockCipherView2 = new CipherView();
mockCipherView2.name = "Test Cipher 2";
const sdkServiceSpy = jest.spyOn(cipherSdkService, "getAllDecrypted").mockResolvedValue({
successes: [mockCipherView1, mockCipherView2],
failures: [],
});
const result = await cipherService.getAllDecrypted(mockUserId);
expect(mockSdkClient.take).toHaveBeenCalled();
expect(mockCiphersSdk.list).toHaveBeenCalled();
expect(sdkServiceSpy).toHaveBeenCalledWith(mockUserId);
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 () => {
it("should not call cipherSdkService 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
const sdkServiceSpy = jest.spyOn(cipherSdkService, "getAllDecrypted");
// Just verify SDK service 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({});
@@ -1435,10 +1380,10 @@ describe("Cipher Service", () => {
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
// We just want to verify SDK service wasn't called
}
expect(mockSdkClient.take).not.toHaveBeenCalled();
expect(sdkServiceSpy).not.toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,6 @@ import {
firstValueFrom,
map,
Observable,
of,
Subject,
switchMap,
tap,
@@ -26,7 +25,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 { DECRYPT_ERROR, EncString } from "../../key-management/crypto/models/enc-string";
import { 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";
@@ -490,7 +489,7 @@ export class CipherService implements CipherServiceAbstraction {
FeatureFlag.PM27632_SdkCipherCrudOperations,
);
if (useSdk) {
return this.getAllDecrypted_sdk(userId);
return this.getAllDecryptedUsingSdk(userId);
}
const decCiphers = await this.getDecryptedCiphers(userId);
@@ -514,53 +513,18 @@ export class CipherService implements CipherServiceAbstraction {
return newDecCiphers;
}
private async getAllDecrypted_sdk(userId: UserId): Promise<CipherView[]> {
// 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();
private async getAllDecryptedUsingSdk(userId: UserId): Promise<CipherView[]> {
try {
const result = await this.cipherSdkService.getAllDecrypted(userId);
const decryptResult = await ref.value.vault().ciphers().list();
await this.setDecryptedCipherCache(result.successes, userId);
await this.setFailedDecryptedCiphers(result.failures, userId);
// Convert successes - SDK returns array of SdkCipherView
const successArray = Array.isArray(decryptResult.successes)
? decryptResult.successes
: Array.from(decryptResult.successes ?? []);
const successViews = successArray.map((sdkCipherView: any) =>
CipherView.fromSdkCipherView(sdkCipherView),
);
// Convert failures to CipherView with error markers
const failureArray = Array.isArray(decryptResult.failures)
? decryptResult.failures
: Array.from(decryptResult.failures ?? []);
const failureViews: CipherView[] = failureArray.map((failure: any) => {
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;
return result.successes;
} catch {
// Return empty array on error to maintain existing behavior
return [];
}
}
private async getDecryptedCiphers(userId: UserId) {
@@ -801,7 +765,7 @@ export class CipherService implements CipherServiceAbstraction {
FeatureFlag.PM27632_SdkCipherCrudOperations,
);
if (useSdk) {
return this.getAllFromApiForOrganization_sdk(organizationId, includeMemberItems ?? false);
return this.getAllFromApiForOrganizationUsingSdk(organizationId, includeMemberItems ?? false);
}
const response = await this.apiService.getCiphersOrganization(
@@ -811,7 +775,7 @@ export class CipherService implements CipherServiceAbstraction {
return await this.decryptOrganizationCiphersResponse(response, organizationId);
}
private async getAllFromApiForOrganization_sdk(
private async getAllFromApiForOrganizationUsingSdk(
organizationId: string,
includeMemberItems: boolean,
): Promise<CipherView[]> {
@@ -820,36 +784,21 @@ export class CipherService implements CipherServiceAbstraction {
throw new Error("User ID is required");
}
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()
.admin()
.list_org_ciphers(asUuid(organizationId), includeMemberItems);
try {
const cipherViews = await this.cipherSdkService.getAllFromApiForOrganization(
organizationId,
userId,
includeMemberItems,
);
const cipherViews = decryptResult.successes.map((sdkCipherView: any) =>
CipherView.fromSdkCipherView(sdkCipherView),
);
// Sort by locale (matching existing behavior)
cipherViews.sort(this.getLocaleSortingFunction());
// Sort by locale (matching existing behavior)
cipherViews.sort(this.getLocaleSortingFunction());
return cipherViews;
}),
catchError((error: unknown) => {
this.logService.error(`Failed to list organization ciphers: ${error}`);
return of([]);
}),
),
);
return result;
return cipherViews;
} catch {
// Return empty array on error to maintain existing behavior
return [];
}
}
async getManyFromApiForOrganization(organizationId: string): Promise<CipherView[]> {