1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 16:43:27 +00:00

Merge branch 'main' into km/pm-27331

This commit is contained in:
Thomas Avery
2026-02-23 09:29:30 -06:00
committed by GitHub
647 changed files with 37702 additions and 10151 deletions

View File

@@ -92,6 +92,7 @@ import { CipherRequest } from "../vault/models/request/cipher.request";
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
import { AttachmentResponse } from "../vault/models/response/attachment.response";
import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response";
import { DeleteAttachmentResponse } from "../vault/models/response/delete-attachment.response";
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
/**
@@ -243,8 +244,14 @@ export abstract class ApiService {
id: string,
request: AttachmentRequest,
): Promise<AttachmentUploadDataResponse>;
abstract deleteCipherAttachment(id: string, attachmentId: string): Promise<any>;
abstract deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any>;
abstract deleteCipherAttachment(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse>;
abstract deleteCipherAttachmentAdmin(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse>;
abstract postShareCipherAttachment(
id: string,
attachmentId: string,

View File

@@ -11,8 +11,7 @@ export class AuthRequestResponse extends BaseResponse {
requestDeviceIdentifier: string;
requestIpAddress: string;
requestCountryName: string;
key: string; // could be either an encrypted MasterKey or an encrypted UserKey
masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey)
key: string; // Auth-request public-key encrypted user-key. Note: No sender authenticity provided!
creationDate: string;
requestApproved?: boolean;
responseDate?: string;
@@ -30,7 +29,6 @@ export class AuthRequestResponse extends BaseResponse {
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
this.requestCountryName = this.getResponseProperty("RequestCountryName");
this.key = this.getResponseProperty("Key");
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
this.creationDate = this.getResponseProperty("CreationDate");
this.requestApproved = this.getResponseProperty("RequestApproved");
this.responseDate = this.getResponseProperty("ResponseDate");

View File

@@ -21,11 +21,7 @@ export abstract class BillingApiServiceAbstraction {
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNext(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNextSelfHost(
abstract getOrganizationBillingMetadataSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;

View File

@@ -36,20 +36,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
async getOrganizationBillingMetadata(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/metadata",
null,
true,
true,
);
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationBillingMetadataVNext(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
@@ -62,7 +48,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationBillingMetadataVNextSelfHost(
async getOrganizationBillingMetadataSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(

View File

@@ -1,13 +1,11 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { firstValueFrom } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { newGuid } from "@bitwarden/guid";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { OrganizationId } from "../../../types/guid";
import { DefaultOrganizationMetadataService } from "./organization-metadata.service";
@@ -15,9 +13,7 @@ import { DefaultOrganizationMetadataService } from "./organization-metadata.serv
describe("DefaultOrganizationMetadataService", () => {
let service: DefaultOrganizationMetadataService;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let configService: jest.Mocked<ConfigService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let featureFlagSubject: BehaviorSubject<boolean>;
const mockOrganizationId = newGuid() as OrganizationId;
const mockOrganizationId2 = newGuid() as OrganizationId;
@@ -34,182 +30,114 @@ describe("DefaultOrganizationMetadataService", () => {
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
platformUtilsService = mock<PlatformUtilsService>();
featureFlagSubject = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
platformUtilsService.isSelfHost.mockReturnValue(false);
service = new DefaultOrganizationMetadataService(
billingApiService,
configService,
platformUtilsService,
);
service = new DefaultOrganizationMetadataService(billingApiService, platformUtilsService);
});
afterEach(() => {
jest.resetAllMocks();
featureFlagSubject.complete();
});
describe("getOrganizationMetadata$", () => {
describe("feature flag OFF", () => {
beforeEach(() => {
featureFlagSubject.next(false);
});
it("calls getOrganizationBillingMetadata for cloud-hosted", async () => {
const mockResponse = createMockMetadataResponse(false, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
it("calls getOrganizationBillingMetadata when feature flag is off", async () => {
const mockResponse = createMockMetadataResponse(false, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
it("does not cache metadata when feature flag is off", async () => {
const mockResponse1 = createMockMetadataResponse(false, 10);
const mockResponse2 = createMockMetadataResponse(false, 15);
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
});
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(result).toEqual(mockResponse);
});
describe("feature flag ON", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("calls getOrganizationBillingMetadataSelfHost when isSelfHost is true", async () => {
platformUtilsService.isSelfHost.mockReturnValue(true);
const mockResponse = createMockMetadataResponse(true, 25);
billingApiService.getOrganizationBillingMetadataSelfHost.mockResolvedValue(mockResponse);
it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => {
const mockResponse = createMockMetadataResponse(true, 15);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
it("caches metadata by organization ID when feature flag is on", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
});
it("maintains separate cache entries for different organization IDs", async () => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
billingApiService.getOrganizationBillingMetadataVNext
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
1,
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
2,
mockOrganizationId2,
);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => {
platformUtilsService.isSelfHost.mockReturnValue(true);
const mockResponse = createMockMetadataResponse(true, 25);
billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue(
mockResponse,
);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadataSelfHost).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
describe("shareReplay behavior", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("caches metadata by organization ID", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
});
const subscription1Promise = firstValueFrom(metadata$);
const subscription2Promise = firstValueFrom(metadata$);
const subscription3Promise = firstValueFrom(metadata$);
it("maintains separate cache entries for different organization IDs", async () => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const [result1, result2, result3] = await Promise.all([
subscription1Promise,
subscription2Promise,
subscription3Promise,
]);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
expect(result3).toEqual(mockResponse);
});
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith(
1,
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith(
2,
mockOrganizationId2,
);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
const subscription1Promise = firstValueFrom(metadata$);
const subscription2Promise = firstValueFrom(metadata$);
const subscription3Promise = firstValueFrom(metadata$);
const [result1, result2, result3] = await Promise.all([
subscription1Promise,
subscription2Promise,
subscription3Promise,
]);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
expect(result3).toEqual(mockResponse);
});
});
describe("refreshMetadataCache", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("refreshes cached metadata when called with feature flag on", (done) => {
it("refreshes cached metadata when called", (done) => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(true, 20);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadataVNext
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
@@ -221,7 +149,7 @@ describe("DefaultOrganizationMetadataService", () => {
expect(result).toEqual(mockResponse1);
} else if (invocationCount === 2) {
expect(result).toEqual(mockResponse2);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
done();
}
@@ -234,45 +162,13 @@ describe("DefaultOrganizationMetadataService", () => {
}, 10);
});
it("does trigger refresh when feature flag is disabled", async () => {
featureFlagSubject.next(false);
const mockResponse1 = createMockMetadataResponse(false, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
next: () => {
invocationCount++;
},
});
// wait for initial invocation
await new Promise((resolve) => setTimeout(resolve, 10));
expect(invocationCount).toBe(1);
service.refreshMetadataCache();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(invocationCount).toBe(2);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
});
it("bypasses cache when refreshing metadata", (done) => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(true, 20);
const mockResponse3 = createMockMetadataResponse(true, 30);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadataVNext
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2)
.mockResolvedValueOnce(mockResponse3);
@@ -289,7 +185,7 @@ describe("DefaultOrganizationMetadataService", () => {
service.refreshMetadataCache();
} else if (invocationCount === 3) {
expect(result).toEqual(mockResponse3);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(3);
subscription.unsubscribe();
done();
}

View File

@@ -1,10 +1,8 @@
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
import { BehaviorSubject, from, Observable, shareReplay, switchMap } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { OrganizationId } from "../../../types/guid";
import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response";
@@ -17,7 +15,6 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
) {}
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
@@ -28,50 +25,26 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
};
getOrganizationMetadata$(orgId: OrganizationId): Observable<OrganizationBillingMetadataResponse> {
return combineLatest([
this.refreshMetadataTrigger,
this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure),
]).pipe(
switchMap(([_, featureFlagEnabled]) =>
featureFlagEnabled
? this.vNextGetOrganizationMetadataInternal$(orgId)
: this.getOrganizationMetadataInternal$(orgId),
),
);
}
private vNextGetOrganizationMetadataInternal$(
orgId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> {
const cacheHit = this.metadataCache.get(orgId);
if (cacheHit) {
return cacheHit;
}
const result = from(this.fetchMetadata(orgId, true)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
this.metadataCache.set(orgId, result);
return result;
}
private getOrganizationMetadataInternal$(
organizationId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> {
return from(this.fetchMetadata(organizationId, false)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
return this.refreshMetadataTrigger.pipe(
switchMap(() => {
const cacheHit = this.metadataCache.get(orgId);
if (cacheHit) {
return cacheHit;
}
const result = from(this.fetchMetadata(orgId)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
this.metadataCache.set(orgId, result);
return result;
}),
);
}
private async fetchMetadata(
organizationId: OrganizationId,
featureFlagEnabled: boolean,
): Promise<OrganizationBillingMetadataResponse> {
return featureFlagEnabled
? this.platformUtilsService.isSelfHost()
? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId)
: await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
return this.platformUtilsService.isSelfHost()
? await this.billingApiService.getOrganizationBillingMetadataSelfHost(organizationId)
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
}
}

View File

@@ -231,6 +231,7 @@ describe("DefaultSubscriptionPricingService", () => {
},
storage: {
price: 4,
provided: 1,
},
} as PremiumPlanResponse;
@@ -350,7 +351,7 @@ describe("DefaultSubscriptionPricingService", () => {
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
configService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(environmentService);
service = new DefaultSubscriptionPricingService(
@@ -915,7 +916,7 @@ describe("DefaultSubscriptionPricingService", () => {
const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
const errorService = new DefaultSubscriptionPricingService(
@@ -959,71 +960,16 @@ describe("DefaultSubscriptionPricingService", () => {
expect(getPlansResponse).toHaveBeenCalledTimes(1);
});
it("should share premium plan API response between multiple subscriptions when feature flag is enabled", () => {
// Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(newEnvironmentService);
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
// Create a new service instance with the feature flag enabled
const newService = new DefaultSubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
it("should share premium plan API response between multiple subscriptions", () => {
const getPremiumPlanSpy = jest.spyOn(billingApiService, "getPremiumPlan");
// Subscribe to the premium pricing tier multiple times
newService.getPersonalSubscriptionPricingTiers$().subscribe();
newService.getPersonalSubscriptionPricingTiers$().subscribe();
service.getPersonalSubscriptionPricingTiers$().subscribe();
service.getPersonalSubscriptionPricingTiers$().subscribe();
// API should only be called once due to shareReplay on premiumPlanResponse$
expect(getPremiumPlanSpy).toHaveBeenCalledTimes(1);
});
it("should use hardcoded premium price when feature flag is disabled", (done) => {
// Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({
seat: { price: 999 }, // Different price to verify hardcoded value is used
storage: { price: 999 },
} as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(newEnvironmentService);
// Create a new service instance with the feature flag disabled
const newService = new DefaultSubscriptionPricingService(
newBillingApiService,
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
// Subscribe with feature flag disabled
newService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
const premiumTier = tiers.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
);
// Should use hardcoded value of 10, not the API response value of 999
expect(premiumTier!.passwordManager.annualPrice).toBe(10);
expect(premiumTier!.passwordManager.annualPricePerAdditionalStorageGB).toBe(4);
done();
});
});
});
describe("Self-hosted environment behavior", () => {
@@ -1035,7 +981,7 @@ describe("DefaultSubscriptionPricingService", () => {
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
@@ -1061,7 +1007,7 @@ describe("DefaultSubscriptionPricingService", () => {
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(

View File

@@ -33,16 +33,6 @@ import {
} from "../types/subscription-pricing-tier";
export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction {
/**
* Fallback premium pricing used when the feature flag is disabled.
* These values represent the legacy pricing model and will not reflect
* server-side price changes. They are retained for backward compatibility
* during the feature flag rollout period.
*/
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
private static readonly FALLBACK_PREMIUM_PROVIDED_STORAGE_GB = 1;
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
@@ -123,45 +113,27 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
.pipe(
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
switchMap((fetchPremiumFromPricingService) =>
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat?.price,
storage: premiumPlan.storage?.price,
provided: premiumPlan.storage?.provided,
})),
)
: of({
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
provided: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_PROVIDED_STORAGE_GB,
}),
),
map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("advancedOnlineSecurity"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: premiumPrices.seat,
annualPricePerAdditionalStorageGB: premiumPrices.storage,
providedStorageGB: premiumPrices.provided,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("advancedOnlineSecurity"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: premiumPlan.seat?.price,
annualPricePerAdditionalStorageGB: premiumPlan.storage?.price,
providedStorageGB: premiumPlan.storage?.provided,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
this.featureTranslations.emergencyAccess(),
this.featureTranslations.breachMonitoring(),
this.featureTranslations.andMoreFeatures(),
],
},
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(

View File

@@ -5,4 +5,5 @@ export enum EventSystemUser {
SCIM = 1,
DomainVerification = 2,
PublicApi = 3,
BitwardenPortal = 5,
}

View File

@@ -60,6 +60,7 @@ export enum EventType {
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515,
OrganizationUser_Left = 1516,
OrganizationUser_AutomaticallyConfirmed = 1517,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,
@@ -81,6 +82,10 @@ export enum EventType {
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617,
Organization_ItemOrganization_Accepted = 1618,
Organization_ItemOrganization_Declined = 1619,
Organization_AutoConfirmEnabled_Admin = 1620,
Organization_AutoConfirmDisabled_Admin = 1621,
Organization_AutoConfirmEnabled_Portal = 1622,
Organization_AutoConfirmDisabled_Portal = 1623,
Policy_Updated = 1700,

View File

@@ -12,15 +12,14 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
export enum FeatureFlag {
/* Admin Console Team */
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
DefaultUserCollectionRestore = "pm-30883-my-items-restored-users",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
SafariAccountSwitching = "pm-5594-safari-account-switching",
PM31088_MasterPasswordServiceEmitSalt = "pm-31088-master-password-service-emit-salt",
/* Autofill */
UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic",
@@ -32,8 +31,6 @@ export enum FeatureFlag {
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
PM26462_Milestone_3 = "pm-26462-milestone-3",
PM23341_Milestone_2 = "pm-23341-milestone-2",
@@ -44,6 +41,7 @@ export enum FeatureFlag {
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
SdkKeyRotation = "pm-30144-sdk-key-rotation",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
PasskeyUnlock = "pm-2035-passkey-unlock",
@@ -55,7 +53,6 @@ export enum FeatureFlag {
/* Tools */
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
SendUIRefresh = "pm-28175-send-ui-refresh",
SendEmailOTP = "pm-19051-send-email-verification",
@@ -73,6 +70,11 @@ export enum FeatureFlag {
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
PM30521_AutofillButtonViewLoginScreen = "pm-30521-autofill-button-view-login-screen",
PM29438_WelcomeDialogWithExtensionPrompt = "pm-29438-welcome-dialog-with-extension-prompt",
PM29438_DialogWithExtensionPromptAccountAge = "pm-29438-dialog-with-extension-prompt-account-age",
PM29437_WelcomeDialog = "pm-29437-welcome-dialog-no-ext-prompt",
PM31039ItemActionInExtension = "pm-31039-item-action-in-extension",
/* Platform */
ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework",
@@ -108,9 +110,7 @@ const FALSE = false as boolean;
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.DefaultUserCollectionRestore]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
[FeatureFlag.BulkReinviteUI]: FALSE,
/* Autofill */
@@ -119,10 +119,10 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
[FeatureFlag.PM31039ItemActionInExtension]: FALSE,
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
[FeatureFlag.SendUIRefresh]: FALSE,
[FeatureFlag.SendEmailOTP]: FALSE,
@@ -140,17 +140,20 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
[FeatureFlag.PM30521_AutofillButtonViewLoginScreen]: FALSE,
[FeatureFlag.PM29438_WelcomeDialogWithExtensionPrompt]: FALSE,
[FeatureFlag.PM29438_DialogWithExtensionPromptAccountAge]: 5,
[FeatureFlag.PM29437_WelcomeDialog]: FALSE,
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
[FeatureFlag.SafariAccountSwitching]: FALSE,
[FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,
[FeatureFlag.PM23341_Milestone_2]: FALSE,
@@ -161,6 +164,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.SdkKeyRotation]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.PasskeyUnlock]: FALSE,

View File

@@ -35,4 +35,5 @@ export enum NotificationType {
ProviderBankAccountVerified = 24,
SyncPolicy = 25,
AutoConfirmMember = 26,
}

View File

@@ -161,13 +161,6 @@ export abstract class EncryptService {
decapsulationKey: Uint8Array,
): Promise<SymmetricCryptoKey>;
/**
* @deprecated Use @see {@link decapsulateKeyUnsigned} instead
* @param data - The ciphertext to decrypt
* @param privateKey - The privateKey to decrypt with
*/
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
/**
* Generates a base64-encoded hash of the given value
* @param value The value to hash

View File

@@ -219,24 +219,4 @@ export class EncryptServiceImplementation implements EncryptService {
);
return new SymmetricCryptoKey(keyBytes);
}
async rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array> {
if (data == null) {
throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption.");
}
switch (data.encryptionType) {
case EncryptionType.Rsa2048_OaepSha1_B64:
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
break;
default:
throw new Error("Invalid encryption type.");
}
if (privateKey == null) {
throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption.");
}
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, "sha1");
}
}

View File

@@ -17,8 +17,11 @@ import {
mockAccountServiceWith,
} from "../../../../spec";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { LogService } from "../../../platform/abstractions/log.service";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { USER_SERVER_CONFIG } from "../../../platform/services/config/default-config.service";
import { UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
import { KeyGenerationService } from "../../crypto";
@@ -92,14 +95,52 @@ describe("MasterPasswordService", () => {
sut.saltForUser$(null as unknown as UserId);
}).toThrow("userId is null or undefined.");
});
// Removable with unwinding of PM31088_MasterPasswordServiceEmitSalt
it("throws when userid present but not in account service", async () => {
await expect(
firstValueFrom(sut.saltForUser$("00000000-0000-0000-0000-000000000001" as UserId)),
).rejects.toThrow("Cannot read properties of undefined (reading 'email')");
});
it("returns salt", async () => {
const salt = await firstValueFrom(sut.saltForUser$(userId));
expect(salt).toBeDefined();
// Removable with unwinding of PM31088_MasterPasswordServiceEmitSalt
it("returns email-derived salt for legacy path", async () => {
const result = await firstValueFrom(sut.saltForUser$(userId));
// mockAccountServiceWith defaults email to "email"
expect(result).toBe("email" as MasterPasswordSalt);
});
describe("saltForUser$ master password unlock data migration path", () => {
// Flagged with PM31088_MasterPasswordServiceEmitSalt PM-31088
beforeEach(() => {
stateProvider.singleUser.getFake(userId, USER_SERVER_CONFIG).nextState({
featureStates: {
[FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: true,
},
} as unknown as ServerConfig);
});
// Unwinding should promote these tests as part of saltForUser suite.
it("returns salt from master password unlock data", async () => {
const expectedSalt = "custom-salt" as MasterPasswordSalt;
const unlockData = new MasterPasswordUnlockData(
expectedSalt,
new PBKDF2KdfConfig(600_000),
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
stateProvider.singleUser
.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY)
.nextState(unlockData.toJSON());
const result = await firstValueFrom(sut.saltForUser$(userId));
expect(result).toBe(expectedSalt);
});
it("throws when master password unlock data is null", async () => {
stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null);
await expect(firstValueFrom(sut.saltForUser$(userId))).rejects.toThrow(
"Master password unlock data not found for user.",
);
});
});
});

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, iif, map, Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
@@ -12,8 +12,10 @@ import { KdfConfig } from "@bitwarden/key-management";
import { PureCrypto } from "@bitwarden/sdk-internal";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
import { LogService } from "../../../platform/abstractions/log.service";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { USER_SERVER_CONFIG } from "../../../platform/services/config/default-config.service";
import {
MASTER_PASSWORD_DISK,
MASTER_PASSWORD_MEMORY,
@@ -102,9 +104,29 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
assertNonNullish(userId, "userId");
return this.accountService.accounts$.pipe(
map((accounts) => accounts[userId].email),
map((email) => this.emailToSalt(email)),
// Note: We can't use the config service as an abstraction here because it creates a circular dependency: ConfigService -> ConfigApiService -> ApiService -> VaultTimeoutSettingsService -> KeyService -> MP service.
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$.pipe(
map((serverConfig) =>
getFeatureFlagValue(serverConfig, FeatureFlag.PM31088_MasterPasswordServiceEmitSalt),
),
switchMap((enabled) =>
iif(
() => enabled,
this.masterPasswordUnlockData$(userId).pipe(
map((unlockData) => {
if (unlockData == null) {
throw new Error("Master password unlock data not found for user.");
}
return unlockData.salt;
}),
),
this.accountService.accounts$.pipe(
map((accounts) => accounts[userId].email),
map((email) => this.emailToSalt(email)),
),
),
),
);
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "../../key-management/crypto/models/enc-string";
import { Folder as FolderDomain } from "../../vault/models/domain/folder";
import { FolderView } from "../../vault/models/view/folder.view";
@@ -7,6 +5,8 @@ import { FolderView } from "../../vault/models/view/folder.view";
import { safeGetString } from "./utils";
export class FolderExport {
name: string = "";
static template(): FolderExport {
const req = new FolderExport();
req.name = "Folder name";
@@ -19,14 +19,12 @@ export class FolderExport {
}
static toDomain(req: FolderExport, domain = new FolderDomain()) {
domain.name = req.name != null ? new EncString(req.name) : null;
domain.name = new EncString(req.name);
return domain;
}
name: string;
// Use build method instead of ctor so that we can control order of JSON stringify for pretty print
build(o: FolderView | FolderDomain) {
this.name = safeGetString(o.name);
this.name = safeGetString(o.name ?? "") ?? "";
}
}

View File

@@ -0,0 +1,63 @@
import { NotificationType } from "../../enums";
import { AutoConfirmMemberNotification, NotificationResponse } from "./notification.response";
describe("NotificationResponse", () => {
describe("AutoConfirmMemberNotification", () => {
it("should parse AutoConfirmMemberNotification payload", () => {
const response = {
ContextId: "context-123",
Type: NotificationType.AutoConfirmMember,
Payload: {
TargetUserId: "target-user-id",
UserId: "user-id",
OrganizationId: "org-id",
},
};
const notification = new NotificationResponse(response);
expect(notification.type).toBe(NotificationType.AutoConfirmMember);
expect(notification.payload).toBeInstanceOf(AutoConfirmMemberNotification);
expect(notification.payload.targetUserId).toBe("target-user-id");
expect(notification.payload.userId).toBe("user-id");
expect(notification.payload.organizationId).toBe("org-id");
});
it("should handle stringified JSON payload", () => {
const response = {
ContextId: "context-123",
Type: NotificationType.AutoConfirmMember,
Payload: JSON.stringify({
TargetUserId: "target-user-id-2",
UserId: "user-id-2",
OrganizationId: "org-id-2",
}),
};
const notification = new NotificationResponse(response);
expect(notification.type).toBe(NotificationType.AutoConfirmMember);
expect(notification.payload).toBeInstanceOf(AutoConfirmMemberNotification);
expect(notification.payload.targetUserId).toBe("target-user-id-2");
expect(notification.payload.userId).toBe("user-id-2");
expect(notification.payload.organizationId).toBe("org-id-2");
});
});
describe("AutoConfirmMemberNotification constructor", () => {
it("should extract all properties from response", () => {
const response = {
TargetUserId: "target-user-id",
UserId: "user-id",
OrganizationId: "org-id",
};
const notification = new AutoConfirmMemberNotification(response);
expect(notification.targetUserId).toBe("target-user-id");
expect(notification.userId).toBe("user-id");
expect(notification.organizationId).toBe("org-id");
});
});
});

View File

@@ -75,6 +75,9 @@ export class NotificationResponse extends BaseResponse {
case NotificationType.SyncPolicy:
this.payload = new SyncPolicyNotification(payload);
break;
case NotificationType.AutoConfirmMember:
this.payload = new AutoConfirmMemberNotification(payload);
break;
default:
break;
}
@@ -210,3 +213,16 @@ export class LogOutNotification extends BaseResponse {
this.reason = this.getResponseProperty("Reason");
}
}
export class AutoConfirmMemberNotification extends BaseResponse {
userId: string;
targetUserId: string;
organizationId: string;
constructor(response: any) {
super(response);
this.targetUserId = this.getResponseProperty("TargetUserId");
this.userId = this.getResponseProperty("UserId");
this.organizationId = this.getResponseProperty("OrganizationId");
}
}

View File

@@ -417,6 +417,142 @@ describe("Utils Service", () => {
// });
});
describe("fromArrayToHex(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a hex string", () => {
const arr = new Uint8Array([0x00, 0x01, 0x02, 0x0a, 0xff]);
const hexString = Utils.fromArrayToHex(arr);
expect(hexString).toBe("0001020aff");
});
runInBothEnvironments("should return null for null input", () => {
const hexString = Utils.fromArrayToHex(null);
expect(hexString).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const hexString = Utils.fromArrayToHex(arr);
expect(hexString).toBe("");
});
});
describe("fromArrayToB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a b64 string", () => {
const arr = new Uint8Array(asciiHelloWorldArray);
const b64String = Utils.fromArrayToB64(arr);
expect(b64String).toBe(b64HelloWorldString);
});
runInBothEnvironments("should return null for null input", () => {
const b64String = Utils.fromArrayToB64(null);
expect(b64String).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const b64String = Utils.fromArrayToB64(arr);
expect(b64String).toBe("");
});
});
describe("fromArrayToUrlB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a URL-safe b64 string", () => {
// Input that produces +, /, and = in standard base64
const arr = new Uint8Array([251, 255, 254]);
const urlB64String = Utils.fromArrayToUrlB64(arr);
// Standard b64 would be "+//+" with padding, URL-safe removes padding and replaces chars
expect(urlB64String).not.toContain("+");
expect(urlB64String).not.toContain("/");
expect(urlB64String).not.toContain("=");
});
runInBothEnvironments("should return null for null input", () => {
const urlB64String = Utils.fromArrayToUrlB64(null);
expect(urlB64String).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const urlB64String = Utils.fromArrayToUrlB64(arr);
expect(urlB64String).toBe("");
});
});
describe("fromArrayToByteString(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a byte string", () => {
const arr = new Uint8Array(asciiHelloWorldArray);
const byteString = Utils.fromArrayToByteString(arr);
expect(byteString).toBe(asciiHelloWorld);
});
runInBothEnvironments("should return null for null input", () => {
const byteString = Utils.fromArrayToByteString(null);
expect(byteString).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const byteString = Utils.fromArrayToByteString(arr);
expect(byteString).toBe("");
});
});
describe("fromArrayToUtf8(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a UTF-8 string", () => {
const arr = new Uint8Array(asciiHelloWorldArray);
const utf8String = Utils.fromArrayToUtf8(arr);
expect(utf8String).toBe(asciiHelloWorld);
});
runInBothEnvironments("should return null for null input", () => {
const utf8String = Utils.fromArrayToUtf8(null);
expect(utf8String).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const utf8String = Utils.fromArrayToUtf8(arr);
expect(utf8String).toBe("");
});
runInBothEnvironments("should handle multi-byte UTF-8 characters", () => {
// "日本" in UTF-8 bytes
const arr = new Uint8Array([0xe6, 0x97, 0xa5, 0xe6, 0x9c, 0xac]);
const utf8String = Utils.fromArrayToUtf8(arr);
expect(utf8String).toBe("日本");
});
});
describe("Base64 and ArrayBuffer round trip conversions", () => {
const originalIsNode = Utils.isNode;
@@ -447,10 +583,10 @@ describe("Utils Service", () => {
"should correctly round trip convert from base64 to ArrayBuffer and back",
() => {
// Convert known base64 string to ArrayBuffer
const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString).buffer;
const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString);
// Convert the ArrayBuffer back to a base64 string
const roundTrippedB64String = Utils.fromBufferToB64(bufferFromB64);
const roundTrippedB64String = Utils.fromArrayToB64(bufferFromB64);
// Compare the original base64 string with the round-tripped base64 string
expect(roundTrippedB64String).toBe(b64HelloWorldString);

View File

@@ -8,6 +8,8 @@ import { Observable, of, switchMap } from "rxjs";
import { getHostname, parse } from "tldts";
import { Merge } from "type-fest";
import "core-js/proposals/array-buffer-base64";
// 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";
@@ -129,6 +131,78 @@ export class Utils {
return arr;
}
/**
* Converts a Uint8Array to a hexadecimal string.
* @param arr - The Uint8Array to convert.
* @returns The hexadecimal string representation, or null if the input is null.
*/
static fromArrayToHex(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
// @ts-expect-error - polyfilled by core-js
return arr.toHex();
}
/**
* Converts a Uint8Array to a Base64 encoded string.
* @param arr - The Uint8Array to convert.
* @returns The Base64 encoded string, or null if the input is null.
*/
static fromArrayToB64(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
// @ts-expect-error - polyfilled by core-js
return arr.toBase64({ alphabet: "base64" });
}
/**
* Converts a Uint8Array to a URL-safe Base64 encoded string.
* @param arr - The Uint8Array to convert.
* @returns The URL-safe Base64 encoded string, or null if the input is null.
*/
static fromArrayToUrlB64(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
// @ts-expect-error - polyfilled by core-js
return arr.toBase64({ alphabet: "base64url" });
}
/**
* Converts a Uint8Array to a byte string (each byte as a character).
* @param arr - The Uint8Array to convert.
* @returns The byte string representation, or null if the input is null.
*/
static fromArrayToByteString(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
let byteString = "";
for (let i = 0; i < arr.length; i++) {
byteString += String.fromCharCode(arr[i]);
}
return byteString;
}
/**
* Converts a Uint8Array to a UTF-8 decoded string.
* @param arr - The Uint8Array containing UTF-8 encoded bytes.
* @returns The decoded UTF-8 string, or null if the input is null.
*/
static fromArrayToUtf8(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
return BufferLib.from(arr).toString("utf8");
}
/**
* Convert binary data into a Base64 string.
*
@@ -302,7 +376,7 @@ export class Utils {
}
static fromUtf8ToUrlB64(utfStr: string): string {
return Utils.fromBufferToUrlB64(Utils.fromUtf8ToArray(utfStr));
return Utils.fromArrayToUrlB64(Utils.fromUtf8ToArray(utfStr));
}
static fromB64ToUtf8(b64Str: string): string {

View File

@@ -3,6 +3,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
@@ -36,6 +37,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
let configService: MockProxy<ConfigService>;
let policyService: MockProxy<InternalPolicyService>;
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let activeUserAccount$: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
let userAccounts$: BehaviorSubject<ObservedValueOf<AccountService["accounts$"]>>;
@@ -131,6 +133,8 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
policyService = mock<InternalPolicyService>();
autoConfirmService = mock<AutomaticUserConfirmationService>();
defaultServerNotificationsService = new DefaultServerNotificationsService(
mock<LogService>(),
syncService,
@@ -145,6 +149,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
authRequestAnsweringService,
configService,
policyService,
autoConfirmService,
);
});

View File

@@ -4,6 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subj
// 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 { LogoutReason } from "@bitwarden/auth/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
@@ -45,6 +46,7 @@ describe("NotificationsService", () => {
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
let configService: MockProxy<ConfigService>;
let policyService: MockProxy<InternalPolicyService>;
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let activeAccount: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
let accounts: BehaviorSubject<ObservedValueOf<AccountService["accounts$"]>>;
@@ -75,6 +77,7 @@ describe("NotificationsService", () => {
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
configService = mock<ConfigService>();
policyService = mock<InternalPolicyService>();
autoConfirmService = mock<AutomaticUserConfirmationService>();
// For these tests, use the active-user implementation (feature flag disabled)
configService.getFeatureFlag$.mockImplementation(() => of(true));
@@ -128,6 +131,7 @@ describe("NotificationsService", () => {
authRequestAnsweringService,
configService,
policyService,
autoConfirmService,
);
});
@@ -507,5 +511,29 @@ describe("NotificationsService", () => {
});
});
});
describe("NotificationType.AutoConfirmMember", () => {
it("should call autoConfirmService.autoConfirmUser with correct parameters", async () => {
autoConfirmService.autoConfirmUser.mockResolvedValue();
const notification = new NotificationResponse({
type: NotificationType.AutoConfirmMember,
payload: {
UserId: mockUser1,
TargetUserId: "target-user-id",
OrganizationId: "org-id",
},
contextId: "different-app-id",
});
await sut["processNotification"](notification, mockUser1);
expect(autoConfirmService.autoConfirmUser).toHaveBeenCalledWith(
mockUser1,
"target-user-id",
"org-id",
);
});
});
});
});

View File

@@ -15,6 +15,7 @@ import {
// 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 { LogoutReason } from "@bitwarden/auth/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
@@ -49,6 +50,7 @@ export const DISABLED_NOTIFICATIONS_URL = "http://-";
export const AllowedMultiUserNotificationTypes = new Set<NotificationType>([
NotificationType.AuthRequest,
NotificationType.AutoConfirmMember,
]);
export class DefaultServerNotificationsService implements ServerNotificationsService {
@@ -70,6 +72,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
private readonly authRequestAnsweringService: AuthRequestAnsweringService,
private readonly configService: ConfigService,
private readonly policyService: InternalPolicyService,
private autoConfirmService: AutomaticUserConfirmationService,
) {
this.notifications$ = this.accountService.accounts$.pipe(
map((accounts: Record<UserId, AccountInfo>): Set<UserId> => {
@@ -292,6 +295,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
case NotificationType.SyncPolicy:
await this.policyService.syncPolicy(PolicyData.fromPolicy(notification.payload.policy));
break;
case NotificationType.AutoConfirmMember:
await this.autoConfirmService.autoConfirmUser(
notification.payload.userId,
notification.payload.targetUserId,
notification.payload.organizationId,
);
break;
default:
break;
}

View File

@@ -183,6 +183,8 @@ export class DefaultSyncService extends CoreSyncService {
const response = await this.inFlightApiCalls.sync;
await this.cipherService.clear(response.profile.id);
await this.syncUserDecryption(response.profile.id, response.userDecryption);
await this.syncProfile(response.profile);
await this.syncFolders(response.folders, response.profile.id);

View File

@@ -115,6 +115,7 @@ import { CipherRequest } from "../vault/models/request/cipher.request";
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
import { AttachmentResponse } from "../vault/models/response/attachment.response";
import { CipherResponse } from "../vault/models/response/cipher.response";
import { DeleteAttachmentResponse } from "../vault/models/response/delete-attachment.response";
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
import { InsecureUrlNotAllowedError } from "./api-errors";
@@ -590,18 +591,32 @@ export class ApiService implements ApiServiceAbstraction {
return new AttachmentUploadDataResponse(r);
}
deleteCipherAttachment(id: string, attachmentId: string): Promise<any> {
return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true);
async deleteCipherAttachment(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse> {
const r = await this.send(
"DELETE",
"/ciphers/" + id + "/attachment/" + attachmentId,
null,
true,
true,
);
return new DeleteAttachmentResponse(r);
}
deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any> {
return this.send(
async deleteCipherAttachmentAdmin(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse> {
const r = await this.send(
"DELETE",
"/ciphers/" + id + "/attachment/" + attachmentId + "/admin",
null,
true,
true,
);
return new DeleteAttachmentResponse(r);
}
postShareCipherAttachment(

View File

@@ -105,7 +105,7 @@ export class SendApiService implements SendApiServiceAbstraction {
"POST",
"/sends/access/file/" + send.file.id,
null,
true,
false,
true,
apiUrl,
setAuthTokenHeader,

View File

@@ -2,7 +2,6 @@ import { Observable } from "rxjs";
import { SendView } from "../../tools/send/models/view/send.view";
import { IndexedEntityId, UserId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService {
@@ -20,7 +19,7 @@ export abstract class SearchService {
abstract isSearchable(userId: UserId, query: string | null): Promise<boolean>;
abstract indexCiphers(
userId: UserId,
ciphersToIndex: CipherView[],
ciphersToIndex: CipherViewLike[],
indexedEntityGuid?: string,
): Promise<void>;
abstract searchCiphers<C extends CipherViewLike>(

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { FolderResponse } from "../response/folder.response";
@@ -10,12 +8,19 @@ export class FolderData {
revisionDate: string;
constructor(response: Partial<FolderResponse>) {
this.name = response?.name;
this.id = response?.id;
this.revisionDate = response?.revisionDate;
this.name = response.name ?? "";
this.id = response.id ?? "";
this.revisionDate = response.revisionDate ?? new Date().toISOString();
}
static fromJSON(obj: Jsonify<FolderData>) {
return Object.assign(new FolderData({}), obj);
static fromJSON(obj: Jsonify<FolderData | null>) {
if (obj == null) {
return null;
}
return new FolderData({
id: obj.id,
name: obj.name,
revisionDate: obj.revisionDate,
});
}
}

View File

@@ -8,7 +8,7 @@ import {
mockFromJson,
} from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { FolderData } from "../../models/data/folder.data";
import { Folder } from "../../models/domain/folder";
@@ -49,6 +49,30 @@ describe("Folder", () => {
});
});
describe("constructor", () => {
it("initializes properties from FolderData", () => {
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
const folder = new Folder({
id: "id",
name: "name",
revisionDate: revisionDate.toISOString(),
});
expect(folder.id).toBe("id");
expect(folder.revisionDate).toEqual(revisionDate);
expect(folder.name).toBeInstanceOf(EncString);
expect((folder.name as EncString).encryptedString).toBe("name");
});
it("initializes empty properties when no FolderData is provided", () => {
const folder = new Folder();
expect(folder.id).toBe("");
expect(folder.name).toBeInstanceOf(EncString);
expect(folder.revisionDate).toBeInstanceOf(Date);
});
});
describe("fromJSON", () => {
jest.mock("../../../key-management/crypto/models/enc-string");
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
@@ -57,17 +81,13 @@ describe("Folder", () => {
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
const actual = Folder.fromJSON({
revisionDate: revisionDate.toISOString(),
name: "name" as EncryptedString,
name: "name",
id: "id",
});
const expected = {
revisionDate: revisionDate,
name: "name_fromJSON",
id: "id",
};
expect(actual).toMatchObject(expected);
expect(actual?.id).toBe("id");
expect(actual?.revisionDate).toEqual(revisionDate);
expect(actual?.name).toBe("name_fromJSON");
});
});
@@ -89,9 +109,7 @@ describe("Folder", () => {
const view = await folder.decryptWithKey(key, encryptService);
expect(view).toEqual({
name: "encName",
});
expect(view.name).toBe("encName");
});
it("assigns the folder id and revision date", async () => {

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
@@ -9,16 +7,10 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
import { FolderData } from "../data/folder.data";
import { FolderView } from "../view/folder.view";
export class Test extends Domain {
id: string;
name: EncString;
revisionDate: Date;
}
export class Folder extends Domain {
id: string;
name: EncString;
revisionDate: Date;
id: string = "";
name: EncString = new EncString("");
revisionDate: Date = new Date();
constructor(obj?: FolderData) {
super();
@@ -26,17 +18,9 @@ export class Folder extends Domain {
return;
}
this.buildDomainModel(
this,
obj,
{
id: null,
name: null,
},
["id"],
);
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
this.id = obj.id;
this.name = new EncString(obj.name);
this.revisionDate = new Date(obj.revisionDate);
}
decrypt(key: SymmetricCryptoKey): Promise<FolderView> {
@@ -62,7 +46,14 @@ export class Folder extends Domain {
}
static fromJSON(obj: Jsonify<Folder>) {
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate });
if (obj == null) {
return null;
}
const folder = new Folder();
folder.id = obj.id;
folder.name = EncString.fromJSON(obj.name);
folder.revisionDate = new Date(obj.revisionDate);
return folder;
}
}

View File

@@ -1,12 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Folder } from "../domain/folder";
import { FolderRequest } from "./folder.request";
export class FolderWithIdRequest extends FolderRequest {
/**
* Declared as `string` (not `string | null`) to satisfy the
* {@link UserKeyRotationDataProvider}`<TRequest extends { id: string } | { organizationId: string }>`
* constraint on `FolderService`.
*
* At runtime this is `null` for new import folders. PR #17077 enforced strict type-checking on
* folder models, changing this assignment to `folder.id ?? ""` — causing the importer to send
* `{"id":""}` instead of `{"id":null}`, which the server rejected.
* The `|| null` below restores the pre-migration behavior while `@ts-strict-ignore` above
* allows the `null` assignment against the `string` declaration.
*/
id: string;
constructor(folder: Folder) {
super(folder);
this.id = folder.id;
this.id = folder.id || null;
}
}

View File

@@ -0,0 +1,12 @@
import { BaseResponse } from "../../../models/response/base.response";
import { CipherResponse } from "./cipher.response";
export class DeleteAttachmentResponse extends BaseResponse {
cipher: CipherResponse;
constructor(response: any) {
super(response);
this.cipher = new CipherResponse(this.getResponseProperty("Cipher"));
}
}

View File

@@ -1,19 +1,17 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { View } from "../../../models/view/view";
import { DecryptedObject } from "../../../platform/models/domain/domain-base";
import { Folder } from "../domain/folder";
import { ITreeNodeObject } from "../domain/tree-node";
export class FolderView implements View, ITreeNodeObject {
id: string = null;
name: string = null;
revisionDate: Date = null;
id: string = "";
name: string = "";
revisionDate: Date;
constructor(f?: Folder | DecryptedObject<Folder, "name">) {
constructor(f?: Folder) {
if (!f) {
this.revisionDate = new Date();
return;
}
@@ -22,7 +20,12 @@ export class FolderView implements View, ITreeNodeObject {
}
static fromJSON(obj: Jsonify<FolderView>) {
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
return Object.assign(new FolderView(), obj, { revisionDate });
const folderView = new FolderView();
folderView.id = obj.id ?? "";
folderView.name = obj.name ?? "";
if (obj.revisionDate != null) {
folderView.revisionDate = new Date(obj.revisionDate);
}
return folderView;
}
}

View File

@@ -77,6 +77,7 @@ import { CipherShareRequest } from "../models/request/cipher-share.request";
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
import { CipherRequest } from "../models/request/cipher.request";
import { CipherResponse } from "../models/response/cipher.response";
import { DeleteAttachmentResponse } from "../models/response/delete-attachment.response";
import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
@@ -173,13 +174,14 @@ export class CipherService implements CipherServiceAbstraction {
decryptStartTime = performance.now();
}),
switchMap(async (ciphers) => {
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
void this.setFailedDecryptedCiphers(failures, userId);
// Trigger full decryption and indexing in background
void this.getAllDecrypted(userId);
return decrypted;
return await this.decryptCiphersWithSdk(ciphers, userId, false);
}),
tap((decrypted) => {
tap(([decrypted, failures]) => {
void Promise.all([
this.setFailedDecryptedCiphers(failures, userId),
this.searchService.indexCiphers(userId, decrypted),
]);
this.logService.measure(
decryptStartTime,
"Vault",
@@ -188,10 +190,11 @@ export class CipherService implements CipherServiceAbstraction {
[["Items", decrypted.length]],
);
}),
map(([decrypted]) => decrypted),
);
}),
);
});
}, this.clearCipherViewsForUser$);
/**
* Observable that emits an array of decrypted ciphers for the active user.
@@ -530,6 +533,10 @@ export class CipherService implements CipherServiceAbstraction {
ciphers: Cipher[],
userId: UserId,
): Promise<[CipherView[], CipherView[]] | null> {
if (ciphers.length === 0) {
return [[], []];
}
if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) {
const decryptStartTime = performance.now();
@@ -1191,33 +1198,28 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
admin = false,
): Promise<Cipher> {
const encKey = await this.getKeyForCipherKeyDecryption(cipher, userId);
const cipherKeyEncryptionEnabled = await this.getCipherKeyEncryptionEnabled();
// The organization's symmetric key or the user's user key
const vaultKey = await this.getKeyForCipherKeyDecryption(cipher, userId);
const cipherEncKey =
cipherKeyEncryptionEnabled && cipher.key != null
? ((await this.encryptService.unwrapSymmetricKey(cipher.key, encKey)) as UserKey)
: encKey;
const cipherKeyOrVaultKey =
cipher.key != null
? ((await this.encryptService.unwrapSymmetricKey(cipher.key, vaultKey)) as UserKey)
: vaultKey;
//if cipher key encryption is disabled but the item has an individual key,
//then we rollback to using the user key as the main key of encryption of the item
//in order to keep item and it's attachments with the same encryption level
if (cipher.key != null && !cipherKeyEncryptionEnabled) {
const model = await this.decrypt(cipher, userId);
await this.updateWithServer(model, userId);
}
const encFileName = await this.encryptService.encryptString(filename, cipherKeyOrVaultKey);
const encFileName = await this.encryptService.encryptString(filename, cipherEncKey);
const dataEncKey = await this.keyService.makeDataEncKey(cipherEncKey);
const encData = await this.encryptService.encryptFileData(new Uint8Array(data), dataEncKey[0]);
const attachmentKey = await this.keyService.makeDataEncKey(cipherKeyOrVaultKey);
const encData = await this.encryptService.encryptFileData(
new Uint8Array(data),
attachmentKey[0],
);
const response = await this.cipherFileUploadService.upload(
cipher,
encFileName,
encData,
admin,
dataEncKey,
attachmentKey,
);
const cData = new CipherData(response, cipher.collectionIds);
@@ -1481,16 +1483,16 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
admin: boolean = false,
): Promise<CipherData> {
let cipherResponse = null;
let response: DeleteAttachmentResponse;
try {
cipherResponse = admin
response = admin
? await this.apiService.deleteCipherAttachmentAdmin(id, attachmentId)
: await this.apiService.deleteCipherAttachment(id, attachmentId);
} catch (e) {
return Promise.reject((e as ErrorResponse).getSingleMessage());
}
const cipherData = CipherData.fromJSON(cipherResponse?.cipher);
const cipherData = new CipherData(response.cipher);
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId);
}
@@ -2406,6 +2408,12 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
fullDecryption: boolean = true,
): Promise<[CipherViewLike[], CipherView[]]> {
// Short-circuit if there are no ciphers to decrypt
// Observables reacting to key changes may attempt to decrypt with a stale SDK reference.
if (ciphers.length === 0) {
return [[], []];
}
if (fullDecryption) {
const [decryptedViews, failedViews] = await this.cipherEncryptionService.decryptManyLegacy(
ciphers,

View File

@@ -95,6 +95,7 @@ describe("DefaultCipherEncryptionService", () => {
vault: jest.fn().mockReturnValue({
ciphers: jest.fn().mockReturnValue({
encrypt: jest.fn(),
encrypt_list: jest.fn(),
encrypt_cipher_for_rotation: jest.fn(),
set_fido2_credentials: jest.fn(),
decrypt: jest.fn(),
@@ -280,10 +281,23 @@ describe("DefaultCipherEncryptionService", () => {
name: "encrypted-name-3",
} as unknown as Cipher;
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
cipher: sdkCipher,
encryptedFor: userId,
});
mockSdkClient
.vault()
.ciphers()
.encrypt_list.mockReturnValue([
{
cipher: sdkCipher,
encryptedFor: userId,
},
{
cipher: sdkCipher,
encryptedFor: userId,
},
{
cipher: sdkCipher,
encryptedFor: userId,
},
]);
jest
.spyOn(Cipher, "fromSdkCipher")
@@ -299,7 +313,8 @@ describe("DefaultCipherEncryptionService", () => {
expect(results[1].cipher).toEqual(expectedCipher2);
expect(results[2].cipher).toEqual(expectedCipher3);
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3);
expect(mockSdkClient.vault().ciphers().encrypt_list).toHaveBeenCalledTimes(1);
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
expect(results[0].encryptedFor).toBe(userId);
expect(results[1].encryptedFor).toBe(userId);
@@ -311,7 +326,7 @@ describe("DefaultCipherEncryptionService", () => {
expect(results).toBeDefined();
expect(results.length).toBe(0);
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
expect(mockSdkClient.vault().ciphers().encrypt_list).not.toHaveBeenCalled();
});
});

View File

@@ -65,21 +65,14 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
using ref = sdk.take();
const results: EncryptionContext[] = [];
// TODO: https://bitwarden.atlassian.net/browse/PM-30580
// Replace this loop with a native SDK encryptMany method for better performance.
for (const model of models) {
const sdkCipherView = this.toSdkCipherView(model, ref.value);
const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView);
results.push({
return ref.value
.vault()
.ciphers()
.encrypt_list(models.map((model) => this.toSdkCipherView(model, ref.value)))
.map((encryptionContext) => ({
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId,
});
}
return results;
}));
}),
catchError((error: unknown) => {
this.logService.error(`Failed to encrypt ciphers in batch: ${error}`);

View File

@@ -93,12 +93,12 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti
response: CipherResponse,
uploadData: AttachmentUploadDataResponse,
isAdmin: boolean,
) {
return () => {
): () => Promise<void> {
return async () => {
if (isAdmin) {
return this.apiService.deleteCipherAttachmentAdmin(response.id, uploadData.attachmentId);
await this.apiService.deleteCipherAttachmentAdmin(response.id, uploadData.attachmentId);
} else {
return this.apiService.deleteCipherAttachment(response.id, uploadData.attachmentId);
await this.apiService.deleteCipherAttachment(response.id, uploadData.attachmentId);
}
};
}

View File

@@ -17,11 +17,11 @@ export class FolderApiService implements FolderApiServiceAbstraction {
const request = new FolderRequest(folder);
let response: FolderResponse;
if (folder.id == null) {
if (folder.id) {
response = await this.putFolder(folder.id, request);
} else {
response = await this.postFolder(request);
folder.id = response.id;
} else {
response = await this.putFolder(folder.id, request);
}
const data = new FolderData(response);

View File

@@ -122,6 +122,7 @@ describe("Folder Service", () => {
encryptedString: "ENC",
encryptionType: 0,
},
revisionDate: expect.any(Date),
});
});
@@ -132,7 +133,7 @@ describe("Folder Service", () => {
expect(result).toEqual({
id: "1",
name: makeEncString("ENC_STRING_" + 1),
revisionDate: null,
revisionDate: expect.any(Date),
});
});
@@ -150,12 +151,12 @@ describe("Folder Service", () => {
{
id: "1",
name: makeEncString("ENC_STRING_" + 1),
revisionDate: null,
revisionDate: expect.any(Date),
},
{
id: "2",
name: makeEncString("ENC_STRING_" + 2),
revisionDate: null,
revisionDate: expect.any(Date),
},
]);
});
@@ -167,7 +168,7 @@ describe("Folder Service", () => {
{
id: "4",
name: makeEncString("ENC_STRING_" + 4),
revisionDate: null,
revisionDate: expect.any(Date),
},
]);
});
@@ -203,7 +204,7 @@ describe("Folder Service", () => {
const folderViews = await firstValueFrom(folderService.folderViews$(mockUserId));
expect(folderViews.length).toBe(1);
expect(folderViews[0].id).toBeNull(); // Should be the "No Folder" folder
expect(folderViews[0].id).toEqual(""); // Should be the "No Folder" folder
});
describe("getRotatedData", () => {

View File

@@ -21,7 +21,6 @@ import { IndexedEntityId, UserId } from "../../types/guid";
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
// Time to wait before performing a search after the user stops typing.
@@ -169,7 +168,7 @@ export class SearchService implements SearchServiceAbstraction {
async indexCiphers(
userId: UserId,
ciphers: CipherView[],
ciphers: CipherViewLike[],
indexedEntityId?: string,
): Promise<void> {
if (await this.getIsIndexing(userId)) {
@@ -182,34 +181,47 @@ export class SearchService implements SearchServiceAbstraction {
const builder = new lunr.Builder();
builder.pipeline.add(this.normalizeAccentsPipelineFunction);
builder.ref("id");
builder.field("shortid", { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) });
builder.field("shortid", {
boost: 100,
extractor: (c: CipherViewLike) => uuidAsString(c.id).substr(0, 8),
});
builder.field("name", {
boost: 10,
});
builder.field("subtitle", {
boost: 5,
extractor: (c: CipherView) => {
if (c.subTitle != null && c.type === CipherType.Card) {
return c.subTitle.replace(/\*/g, "");
extractor: (c: CipherViewLike) => {
const subtitle = CipherViewLikeUtils.subtitle(c);
if (subtitle != null && CipherViewLikeUtils.getType(c) === CipherType.Card) {
return subtitle.replace(/\*/g, "");
}
return c.subTitle;
return subtitle;
},
});
builder.field("notes");
builder.field("notes", { extractor: (c: CipherViewLike) => CipherViewLikeUtils.getNotes(c) });
builder.field("login.username", {
extractor: (c: CipherView) =>
c.type === CipherType.Login && c.login != null ? c.login.username : null,
extractor: (c: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(c);
return login?.username ?? null;
},
});
builder.field("login.uris", {
boost: 2,
extractor: (c: CipherViewLike) => this.uriExtractor(c),
});
builder.field("fields", {
extractor: (c: CipherViewLike) => this.fieldExtractor(c, false),
});
builder.field("fields_joined", {
extractor: (c: CipherViewLike) => this.fieldExtractor(c, true),
});
builder.field("login.uris", { boost: 2, extractor: (c: CipherView) => this.uriExtractor(c) });
builder.field("fields", { extractor: (c: CipherView) => this.fieldExtractor(c, false) });
builder.field("fields_joined", { extractor: (c: CipherView) => this.fieldExtractor(c, true) });
builder.field("attachments", {
extractor: (c: CipherView) => this.attachmentExtractor(c, false),
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, false),
});
builder.field("attachments_joined", {
extractor: (c: CipherView) => this.attachmentExtractor(c, true),
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, true),
});
builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId });
builder.field("organizationid", { extractor: (c: CipherViewLike) => c.organizationId });
ciphers = ciphers || [];
ciphers.forEach((c) => builder.add(c));
const index = builder.build();
@@ -400,37 +412,44 @@ export class SearchService implements SearchServiceAbstraction {
return await firstValueFrom(this.searchIsIndexing$(userId));
}
private fieldExtractor(c: CipherView, joined: boolean) {
if (!c.hasFields) {
private fieldExtractor(c: CipherViewLike, joined: boolean) {
const fields = CipherViewLikeUtils.getFields(c);
if (!fields || fields.length === 0) {
return null;
}
let fields: string[] = [];
c.fields.forEach((f) => {
let fieldStrings: string[] = [];
fields.forEach((f) => {
if (f.name != null) {
fields.push(f.name);
fieldStrings.push(f.name);
}
if (f.type === FieldType.Text && f.value != null) {
fields.push(f.value);
// For CipherListView, value is only populated for Text fields
// For CipherView, we check the type explicitly
if (f.value != null) {
const fieldType = (f as { type?: FieldType }).type;
if (fieldType === undefined || fieldType === FieldType.Text) {
fieldStrings.push(f.value);
}
}
});
fields = fields.filter((f) => f.trim() !== "");
if (fields.length === 0) {
fieldStrings = fieldStrings.filter((f) => f.trim() !== "");
if (fieldStrings.length === 0) {
return null;
}
return joined ? fields.join(" ") : fields;
return joined ? fieldStrings.join(" ") : fieldStrings;
}
private attachmentExtractor(c: CipherView, joined: boolean) {
if (!c.hasAttachments) {
private attachmentExtractor(c: CipherViewLike, joined: boolean) {
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(c);
if (!attachmentNames || attachmentNames.length === 0) {
return null;
}
let attachments: string[] = [];
c.attachments.forEach((a) => {
if (a != null && a.fileName != null) {
if (joined && a.fileName.indexOf(".") > -1) {
attachments.push(a.fileName.substr(0, a.fileName.lastIndexOf(".")));
attachmentNames.forEach((fileName) => {
if (fileName != null) {
if (joined && fileName.indexOf(".") > -1) {
attachments.push(fileName.substring(0, fileName.lastIndexOf(".")));
} else {
attachments.push(a.fileName);
attachments.push(fileName);
}
}
});
@@ -441,43 +460,39 @@ export class SearchService implements SearchServiceAbstraction {
return joined ? attachments.join(" ") : attachments;
}
private uriExtractor(c: CipherView) {
if (c.type !== CipherType.Login || c.login == null || !c.login.hasUris) {
private uriExtractor(c: CipherViewLike) {
if (CipherViewLikeUtils.getType(c) !== CipherType.Login) {
return null;
}
const login = CipherViewLikeUtils.getLogin(c);
if (!login?.uris?.length) {
return null;
}
const uris: string[] = [];
c.login.uris.forEach((u) => {
login.uris.forEach((u) => {
if (u.uri == null || u.uri === "") {
return;
}
// Match ports
// Extract port from URI
const portMatch = u.uri.match(/:(\d+)(?:[/?#]|$)/);
const port = portMatch?.[1];
let uri = u.uri;
if (u.hostname !== null) {
uris.push(u.hostname);
const hostname = CipherViewLikeUtils.getUriHostname(u);
if (hostname !== undefined) {
uris.push(hostname);
if (port) {
uris.push(`${u.hostname}:${port}`);
uris.push(port);
}
return;
} else {
const slash = uri.indexOf("/");
const hostPart = slash > -1 ? uri.substring(0, slash) : uri;
uris.push(hostPart);
if (port) {
uris.push(`${hostPart}`);
uris.push(`${hostname}:${port}`);
uris.push(port);
}
}
// Add processed URI (strip protocol and query params for non-regex matches)
let uri = u.uri;
if (u.match !== UriMatchStrategy.RegularExpression) {
const protocolIndex = uri.indexOf("://");
if (protocolIndex > -1) {
uri = uri.substr(protocolIndex + 3);
uri = uri.substring(protocolIndex + 3);
}
const queryIndex = uri.search(/\?|&|#/);
if (queryIndex > -1) {
@@ -486,6 +501,7 @@ export class SearchService implements SearchServiceAbstraction {
}
uris.push(uri);
});
return uris.length > 0 ? uris : null;
}

View File

@@ -651,4 +651,198 @@ describe("CipherViewLikeUtils", () => {
expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false);
});
});
describe("getNotes", () => {
describe("CipherView", () => {
it("returns notes when present", () => {
const cipherView = createCipherView();
cipherView.notes = "This is a test note";
expect(CipherViewLikeUtils.getNotes(cipherView)).toBe("This is a test note");
});
it("returns undefined when notes are not present", () => {
const cipherView = createCipherView();
cipherView.notes = undefined;
expect(CipherViewLikeUtils.getNotes(cipherView)).toBeUndefined();
});
});
describe("CipherListView", () => {
it("returns notes when present", () => {
const cipherListView = {
type: "secureNote",
notes: "List view notes",
} as CipherListView;
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBe("List view notes");
});
it("returns undefined when notes are not present", () => {
const cipherListView = {
type: "secureNote",
} as CipherListView;
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBeUndefined();
});
});
});
describe("getFields", () => {
describe("CipherView", () => {
it("returns fields when present", () => {
const cipherView = createCipherView();
cipherView.fields = [
{ name: "Field1", value: "Value1" } as any,
{ name: "Field2", value: "Value2" } as any,
];
const fields = CipherViewLikeUtils.getFields(cipherView);
expect(fields).toHaveLength(2);
expect(fields?.[0].name).toBe("Field1");
expect(fields?.[0].value).toBe("Value1");
expect(fields?.[1].name).toBe("Field2");
expect(fields?.[1].value).toBe("Value2");
});
it("returns empty array when fields array is empty", () => {
const cipherView = createCipherView();
cipherView.fields = [];
expect(CipherViewLikeUtils.getFields(cipherView)).toEqual([]);
});
});
describe("CipherListView", () => {
it("returns fields when present", () => {
const cipherListView = {
type: { login: {} },
fields: [
{ name: "Username", value: "user@example.com" },
{ name: "API Key", value: "abc123" },
],
} as CipherListView;
const fields = CipherViewLikeUtils.getFields(cipherListView);
expect(fields).toHaveLength(2);
expect(fields?.[0].name).toBe("Username");
expect(fields?.[0].value).toBe("user@example.com");
expect(fields?.[1].name).toBe("API Key");
expect(fields?.[1].value).toBe("abc123");
});
it("returns empty array when fields array is empty", () => {
const cipherListView = {
type: "secureNote",
fields: [],
} as unknown as CipherListView;
expect(CipherViewLikeUtils.getFields(cipherListView)).toEqual([]);
});
it("returns undefined when fields are not present", () => {
const cipherListView = {
type: "secureNote",
} as CipherListView;
expect(CipherViewLikeUtils.getFields(cipherListView)).toBeUndefined();
});
});
});
describe("getAttachmentNames", () => {
describe("CipherView", () => {
it("returns attachment filenames when present", () => {
const cipherView = createCipherView();
const attachment1 = new AttachmentView();
attachment1.id = "1";
attachment1.fileName = "document.pdf";
const attachment2 = new AttachmentView();
attachment2.id = "2";
attachment2.fileName = "image.png";
const attachment3 = new AttachmentView();
attachment3.id = "3";
attachment3.fileName = "spreadsheet.xlsx";
cipherView.attachments = [attachment1, attachment2, attachment3];
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
expect(attachmentNames).toEqual(["document.pdf", "image.png", "spreadsheet.xlsx"]);
});
it("filters out null and undefined filenames", () => {
const cipherView = createCipherView();
const attachment1 = new AttachmentView();
attachment1.id = "1";
attachment1.fileName = "valid.pdf";
const attachment2 = new AttachmentView();
attachment2.id = "2";
attachment2.fileName = null as any;
const attachment3 = new AttachmentView();
attachment3.id = "3";
attachment3.fileName = undefined;
const attachment4 = new AttachmentView();
attachment4.id = "4";
attachment4.fileName = "another.txt";
cipherView.attachments = [attachment1, attachment2, attachment3, attachment4];
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
expect(attachmentNames).toEqual(["valid.pdf", "another.txt"]);
});
it("returns empty array when attachments have no filenames", () => {
const cipherView = createCipherView();
const attachment1 = new AttachmentView();
attachment1.id = "1";
const attachment2 = new AttachmentView();
attachment2.id = "2";
cipherView.attachments = [attachment1, attachment2];
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
expect(attachmentNames).toEqual([]);
});
it("returns empty array for empty attachments array", () => {
const cipherView = createCipherView();
cipherView.attachments = [];
expect(CipherViewLikeUtils.getAttachmentNames(cipherView)).toEqual([]);
});
});
describe("CipherListView", () => {
it("returns attachment names when present", () => {
const cipherListView = {
type: "secureNote",
attachmentNames: ["report.pdf", "photo.jpg", "data.csv"],
} as CipherListView;
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherListView);
expect(attachmentNames).toEqual(["report.pdf", "photo.jpg", "data.csv"]);
});
it("returns empty array when attachmentNames is empty", () => {
const cipherListView = {
type: "secureNote",
attachmentNames: [],
} as unknown as CipherListView;
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toEqual([]);
});
it("returns undefined when attachmentNames is not present", () => {
const cipherListView = {
type: "secureNote",
} as CipherListView;
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toBeUndefined();
});
});
});
});

View File

@@ -10,6 +10,7 @@ import {
LoginUriView as LoginListUriView,
} from "@bitwarden/sdk-internal";
import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums";
import { Cipher } from "../models/domain/cipher";
import { CardView } from "../models/view/card.view";
@@ -290,6 +291,71 @@ export class CipherViewLikeUtils {
static decryptionFailure = (cipher: CipherViewLike): boolean => {
return "decryptionFailure" in cipher ? cipher.decryptionFailure : false;
};
/**
* Returns the notes from the cipher.
*
* @param cipher - The cipher to extract notes from (either `CipherView` or `CipherListView`)
* @returns The notes string if present, or `undefined` if not set
*/
static getNotes = (cipher: CipherViewLike): string | undefined => {
return cipher.notes;
};
/**
* Returns the fields from the cipher.
*
* @param cipher - The cipher to extract fields from (either `CipherView` or `CipherListView`)
* @returns Array of field objects with `name` and `value` properties, `undefined` if not set
*/
static getFields = (
cipher: CipherViewLike,
): { name?: string | null; value?: string | undefined }[] | undefined => {
if (this.isCipherListView(cipher)) {
return cipher.fields;
}
return cipher.fields;
};
/**
* Returns attachment filenames from the cipher.
*
* @param cipher - The cipher to extract attachment names from (either `CipherView` or `CipherListView`)
* @returns Array of attachment filenames, `undefined` if attachments are not present
*/
static getAttachmentNames = (cipher: CipherViewLike): string[] | undefined => {
if (this.isCipherListView(cipher)) {
return cipher.attachmentNames;
}
return cipher.attachments
?.map((a) => a.fileName)
.filter((name): name is string => name != null);
};
/**
* Extracts hostname from a login URI.
*
* @param uri - The URI object (either `LoginUriView` class or `LoginListUriView`)
* @returns The hostname if available, `undefined` otherwise
*
* @remarks
* - For `LoginUriView` (CipherView): Uses the built-in `hostname` getter
* - For `LoginListUriView` (CipherListView): Computes hostname using `Utils.getHostname()`
* - Returns `undefined` for RegularExpression match types or when hostname cannot be extracted
*/
static getUriHostname = (uri: LoginListUriView | LoginUriView): string | undefined => {
if ("hostname" in uri && typeof uri.hostname !== "undefined") {
return uri.hostname ?? undefined;
}
if (uri.match !== UriMatchStrategy.RegularExpression && uri.uri) {
const hostname = Utils.getHostname(uri.uri);
return hostname === "" ? undefined : hostname;
}
return undefined;
};
}
/**