diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 07fdfb9db79..63cd4b534fb 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -26,7 +26,6 @@ const eventsToTest = [ EVENTS.CHANGE, EVENTS.INPUT, EVENTS.KEYDOWN, - EVENTS.KEYPRESS, EVENTS.KEYUP, "blur", "click", @@ -1044,13 +1043,13 @@ describe("InsertAutofillContentService", () => { }); describe("simulateUserKeyboardEventInteractions", () => { - it("will trigger `keydown`, `keypress`, and `keyup` events on the passed element", () => { + it("will trigger `keydown` and `keyup` events on the passed element", () => { const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; jest.spyOn(inputElement, "dispatchEvent"); insertAutofillContentService["simulateUserKeyboardEventInteractions"](inputElement); - [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventName) => { + [EVENTS.KEYDOWN, EVENTS.KEYUP].forEach((eventName) => { expect(inputElement.dispatchEvent).toHaveBeenCalledWith( new KeyboardEvent(eventName, { bubbles: true }), ); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index 9ddbcdc005d..6c951afc1a0 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -136,7 +136,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf setTimeout(() => { this.autofillInsertActions[action]({ opid, value }); resolve(); - }, delayActionsInMilliseconds * actionIndex), + }, delayActionsInMilliseconds), ); }; @@ -349,7 +349,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf * @private */ private simulateUserKeyboardEventInteractions(element: FormFieldElement): void { - const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP]; + const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYUP]; for (let index = 0; index < simulatedKeyboardEvents.length; index++) { element.dispatchEvent(new KeyboardEvent(simulatedKeyboardEvents[index], { bubbles: true })); } diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts index 55385ca0ce9..81930279184 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { firstValueFrom, map, Observable, switchMap } from "rxjs"; import { + DefaultOrganizationUserService, OrganizationUserApiService, OrganizationUserBulkConfirmRequest, OrganizationUserBulkPublicKeyResponse, @@ -26,8 +27,6 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserService } from "../../services/organization-user/organization-user.service"; - import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component"; import { BulkUserDetails } from "./bulk-status.component"; @@ -54,7 +53,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { private organizationUserApiService: OrganizationUserApiService, protected i18nService: I18nService, private stateProvider: StateProvider, - private organizationUserService: OrganizationUserService, + private organizationUserService: DefaultOrganizationUserService, private configService: ConfigService, ) { super(keyService, encryptService, i18nService); diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts index 2ac2d31cd69..baaa33eeae9 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/index.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts @@ -2,4 +2,3 @@ export { OrganizationMembersService } from "./organization-members-service/organ export { MemberActionsService } from "./member-actions/member-actions.service"; export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service"; export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service"; -export { OrganizationUserService } from "./organization-user/organization-user.service"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 6fd7de7b292..e856ab7afd1 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -10,6 +10,7 @@ import { OrganizationUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; @@ -20,7 +21,6 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; -import { BillingConstraintService } from "../../../../../billing/members/billing-constraint/billing-constraint.service"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { OrganizationUserService } from "../organization-user/organization-user.service"; @@ -34,7 +34,7 @@ describe("MemberActionsService", () => { let encryptService: MockProxy; let configService: MockProxy; let accountService: FakeAccountService; - let billingConstraintService: MockProxy; + let organizationMetadataService: MockProxy; const userId = newGuid() as UserId; const organizationId = newGuid() as OrganizationId; @@ -50,7 +50,7 @@ describe("MemberActionsService", () => { encryptService = mock(); configService = mock(); accountService = mockAccountServiceWith(userId); - billingConstraintService = mock(); + organizationMetadataService = mock(); mockOrganization = { id: organizationId, @@ -75,7 +75,7 @@ describe("MemberActionsService", () => { encryptService, configService, accountService, - billingConstraintService, + organizationMetadataService, ); }); @@ -251,7 +251,7 @@ describe("MemberActionsService", () => { expect(result).toEqual({ success: true }); expect(organizationUserService.confirmUser).toHaveBeenCalledWith( mockOrganization, - mockOrgUser, + mockOrgUser.id, publicKey, ); expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 3697aba94ff..2913e90e6c0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@angular/core"; import { firstValueFrom, switchMap, map } from "rxjs"; import { + DefaultOrganizationUserService, OrganizationUserApiService, OrganizationUserBulkResponse, OrganizationUserConfirmRequest, @@ -21,7 +22,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; -import { OrganizationUserService } from "../organization-user/organization-user.service"; export interface MemberActionResult { success: boolean; @@ -39,7 +39,7 @@ export class MemberActionsService { constructor( private organizationUserApiService: OrganizationUserApiService, - private organizationUserService: OrganizationUserService, + private organizationUserService: DefaultOrganizationUserService, private keyService: KeyService, private encryptService: EncryptService, private configService: ConfigService, @@ -129,7 +129,7 @@ export class MemberActionsService { await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) ) { await firstValueFrom( - this.organizationUserService.confirmUser(organization, user, publicKey), + this.organizationUserService.confirmUser(organization, user.id, publicKey), ); } else { const request = await firstValueFrom( diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts index a0b71e598f6..ea74eb67ffc 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts @@ -1,6 +1,5 @@ import { mock, mockReset } from "jest-mock-extended"; -import * as rxjs from "rxjs"; -import { of } from "rxjs"; +import { of, BehaviorSubject } from "rxjs"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -8,6 +7,7 @@ import { AccountService, Account } from "@bitwarden/common/auth/abstractions/acc import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; import { DialogRef, DialogService } from "@bitwarden/components"; @@ -28,6 +28,7 @@ describe("UnifiedUpgradePromptService", () => { const mockDialogService = mock(); const mockOrganizationService = mock(); const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open"); + const mockPlatformUtilsService = mock(); /** * Creates a mock DialogRef that implements the required properties for testing @@ -57,33 +58,33 @@ describe("UnifiedUpgradePromptService", () => { mockSyncService, mockDialogService, mockOrganizationService, + mockPlatformUtilsService, ); } const mockAccount: Account = { id: "test-user-id", } as Account; - const accountSubject = new rxjs.BehaviorSubject(mockAccount); + const accountSubject = new BehaviorSubject(mockAccount); describe("initialization", () => { beforeEach(() => { + mockAccountService.activeAccount$ = accountSubject.asObservable(); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + setupTestService(); }); it("should be created", () => { expect(sut).toBeTruthy(); }); - - it("should subscribe to account and feature flag observables on construction", () => { - expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, - ); - }); }); describe("displayUpgradePromptConditionally", () => { - beforeEach(async () => { + beforeEach(() => { mockAccountService.activeAccount$ = accountSubject.asObservable(); mockDialogOpen.mockReset(); + mockReset(mockDialogService); mockReset(mockConfigService); mockReset(mockBillingService); mockReset(mockVaultProfileService); @@ -93,20 +94,48 @@ describe("UnifiedUpgradePromptService", () => { // Mock sync service methods mockSyncService.fullSync.mockResolvedValue(true); mockSyncService.lastSync$.mockReturnValue(of(new Date())); + mockReset(mockPlatformUtilsService); + }); + it("should subscribe to account and feature flag observables when checking display conditions", async () => { + // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + setupTestService(); + + // Act + await sut.displayUpgradePromptConditionally(); + + // Assert + expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, + ); + expect(mockAccountService.activeAccount$).toBeDefined(); }); it("should not show dialog when feature flag is disabled", async () => { // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + setupTestService(); // Act const result = await sut.displayUpgradePromptConditionally(); // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should not show dialog when user has premium", async () => { // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); @@ -117,6 +146,7 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should not show dialog when user has any organization membership", async () => { @@ -124,6 +154,7 @@ describe("UnifiedUpgradePromptService", () => { mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([{ id: "org1" } as any])); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); setupTestService(); // Act @@ -131,6 +162,7 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should not show dialog when profile is older than 5 minutes", async () => { @@ -141,6 +173,7 @@ describe("UnifiedUpgradePromptService", () => { const oldDate = new Date(); oldDate.setMinutes(oldDate.getMinutes() - 10); // 10 minutes old mockVaultProfileService.getProfileCreationDate.mockResolvedValue(oldDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); setupTestService(); // Act @@ -148,6 +181,7 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should show dialog when all conditions are met", async () => { @@ -158,6 +192,7 @@ describe("UnifiedUpgradePromptService", () => { const recentDate = new Date(); recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed }; mockDialogOpenMethod(createMockDialogRef(expectedResult)); @@ -182,6 +217,7 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); it("should not show dialog when profile creation date is unavailable", async () => { @@ -190,6 +226,8 @@ describe("UnifiedUpgradePromptService", () => { mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + setupTestService(); // Act @@ -197,6 +235,26 @@ describe("UnifiedUpgradePromptService", () => { // Assert expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when running in self-hosted environment", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(true); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts index 8dd7f31275c..cf5deaf37fa 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; -import { combineLatest, firstValueFrom, timeout } from "rxjs"; -import { filter, switchMap, take } from "rxjs/operators"; +import { combineLatest, firstValueFrom, timeout, from, Observable, of } from "rxjs"; +import { filter, switchMap, take, map } from "rxjs/operators"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -8,7 +8,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef, DialogService } from "@bitwarden/components"; import { @@ -29,63 +31,37 @@ export class UnifiedUpgradePromptService { private syncService: SyncService, private dialogService: DialogService, private organizationService: OrganizationService, + private platformUtilsService: PlatformUtilsService, ) {} - private shouldShowPrompt$ = combineLatest([ - this.accountService.activeAccount$, - this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), - ]).pipe( - switchMap(async ([account, isFlagEnabled]) => { - if (!account || !account?.id) { - return false; - } - // Early return if feature flag is disabled - if (!isFlagEnabled) { - return false; + private shouldShowPrompt$: Observable = this.accountService.activeAccount$.pipe( + switchMap((account) => { + // Check self-hosted first before any other operations + if (this.platformUtilsService.isSelfHost()) { + return of(false); } - // Wait for sync to complete to ensure organizations are fully loaded - // Also force a sync to ensure we have the latest data - await this.syncService.fullSync(false); + if (!account) { + return of(false); + } - // Wait for the sync to complete with timeout to prevent hanging - await firstValueFrom( - this.syncService.lastSync$(account.id).pipe( - filter((lastSync) => lastSync !== null), - take(1), - timeout(30000), // 30 second timeout - ), + const isProfileLessThanFiveMinutesOld = from( + this.isProfileLessThanFiveMinutesOld(account.id), ); + const hasOrganizations = from(this.hasOrganizations(account.id)); - // Check if user has premium - const hasPremium = await firstValueFrom( + return combineLatest([ + isProfileLessThanFiveMinutesOld, + hasOrganizations, this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), + ]).pipe( + map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, isFlagEnabled]) => { + return ( + isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && isFlagEnabled + ); + }), ); - - // Early return if user already has premium - if (hasPremium) { - return false; - } - - // Check if user has any organization membership (any status including pending) - // Try using memberOrganizations$ which might have different filtering logic - const memberOrganizations = await firstValueFrom( - this.organizationService.memberOrganizations$(account.id), - ); - - const hasOrganizations = memberOrganizations.length > 0; - - // Early return if user has any organization status - if (hasOrganizations) { - return false; - } - - // Check profile age only if needed - const isProfileLessThanFiveMinutesOld = await this.isProfileLessThanFiveMinutesOld( - account.id, - ); - - return isFlagEnabled && !hasPremium && !hasOrganizations && isProfileLessThanFiveMinutesOld; }), take(1), ); @@ -119,7 +95,7 @@ export class UnifiedUpgradePromptService { const nowInMs = new Date().getTime(); const differenceInMs = nowInMs - createdAtInMs; - const msInAMinute = 1000 * 60; // Milliseconds in a minute for conversion 1 minute = 60 seconds * 1000 ms + const msInAMinute = 1000 * 60; // 60 seconds * 1000ms const differenceInMinutes = Math.round(differenceInMs / msInAMinute); return differenceInMinutes <= 5; @@ -141,4 +117,32 @@ export class UnifiedUpgradePromptService { // Return the result or null if the dialog was dismissed without a result return result || null; } + + /** + * Checks if the user has any organization associated with their account + * @param userId User ID to check + * @returns Promise that resolves to true if user has any organizations, false otherwise + */ + private async hasOrganizations(userId: UserId): Promise { + // Wait for sync to complete to ensure organizations are fully loaded + // Also force a sync to ensure we have the latest data + await this.syncService.fullSync(false); + + // Wait for the sync to complete with timeout to prevent hanging + await firstValueFrom( + this.syncService.lastSync$(userId).pipe( + filter((lastSync) => lastSync !== null), + take(1), + timeout(30000), // 30 second timeout + ), + ); + + // Check if user has any organization membership (any status including pending) + // Try using memberOrganizations$ which might have different filtering logic + const memberOrganizations = await firstValueFrom( + this.organizationService.memberOrganizations$(userId), + ); + + return memberOrganizations.length > 0; + } } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 9b007ae7a6b..39a80c99458 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -54,7 +54,7 @@ @if (isFamiliesPlan) {

diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index f168672f23f..a0ba480fe1e 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -1,12 +1,12 @@ import { - AfterViewChecked, + AfterViewInit, Component, DestroyRef, input, OnInit, output, signal, - ViewChild, + viewChild, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; @@ -19,6 +19,8 @@ import { catchError, of, combineLatest, + map, + shareReplay, } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -96,7 +98,8 @@ export type UpgradePaymentParams = { providers: [UpgradePaymentService], templateUrl: "./upgrade-payment.component.html", }) -export class UpgradePaymentComponent implements OnInit, AfterViewChecked { +export class UpgradePaymentComponent implements OnInit, AfterViewInit { + private readonly INITIAL_TAX_VALUE = 0; protected readonly selectedPlanId = input.required(); protected readonly account = input.required(); protected goBack = output(); @@ -104,12 +107,8 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked { protected selectedPlan: PlanDetails | null = null; protected hasEnoughAccountCredit$!: Observable; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent; + readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent); + readonly cartSummaryComponent = viewChild.required(CartSummaryComponent); protected formGroup = new FormGroup({ organizationName: new FormControl("", [Validators.required]), @@ -118,12 +117,11 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked { }); protected readonly loading = signal(true); - private cartSummaryConfigured = false; private pricingTiers$!: Observable; // Cart Summary data protected passwordManager!: LineItem; - protected estimatedTax = 0; + protected estimatedTax$!: Observable; // Display data protected upgradeToMessage = ""; @@ -165,49 +163,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked { this.upgradeToMessage = this.i18nService.t( this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", ); - - this.estimatedTax = 0; } else { this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); return; } }); - this.formGroup.controls.billingAddress.valueChanges - .pipe( - debounceTime(1000), - // Only proceed when form has required values - switchMap(() => this.refreshSalesTax$()), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((tax) => { - this.estimatedTax = tax; - }); - - // Check if user has enough account credit for the purchase - this.hasEnoughAccountCredit$ = combineLatest([ - this.upgradePaymentService.accountCredit$, - this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), - ]).pipe( - switchMap(([credit, formValue]) => { - const selectedPaymentType = formValue.paymentForm?.type; - if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { - return of(true); // Not using account credit, so this check doesn't apply - } - - return credit ? of(credit >= this.cartSummaryComponent.total()) : of(false); - }), + this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + debounceTime(1000), + // Only proceed when form has required values + switchMap(() => this.refreshSalesTax$()), ); this.loading.set(false); } - ngAfterViewChecked(): void { - // Configure cart summary only once when it becomes available - if (this.cartSummaryComponent && !this.cartSummaryConfigured) { - this.cartSummaryComponent.isExpanded.set(false); - this.cartSummaryConfigured = true; - } + ngAfterViewInit(): void { + const cartSummaryComponent = this.cartSummaryComponent(); + cartSummaryComponent.isExpanded.set(false); + + this.hasEnoughAccountCredit$ = combineLatest([ + cartSummaryComponent.total$, + this.upgradePaymentService.accountCredit$, + this.formGroup.controls.paymentForm.valueChanges.pipe( + startWith(this.formGroup.controls.paymentForm.value), + ), + ]).pipe( + map(([total, credit, currentFormValue]) => { + const selectedPaymentType = currentFormValue?.type; + if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { + return true; // Not using account credit, so this check doesn't apply + } + return credit ? credit >= total : false; + }), + shareReplay({ bufferSize: 1, refCount: true }), // Cache the latest for two async pipes + ); } protected get isPremiumPlan(): boolean { @@ -252,7 +243,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked { }; protected isFormValid(): boolean { - return this.formGroup.valid && this.paymentComponent?.validate(); + return this.formGroup.valid && this.paymentComponent().validate(); } private async processUpgrade(): Promise { @@ -335,17 +326,19 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked { return { type: NonTokenizablePaymentMethods.accountCredit }; } - return await this.paymentComponent?.tokenize(); + return await this.paymentComponent().tokenize(); } // Create an observable for tax calculation private refreshSalesTax$(): Observable { if (this.formGroup.invalid || !this.selectedPlan) { - return of(0); + return of(this.INITIAL_TAX_VALUE); } const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - + if (!billingAddress.country || !billingAddress.postalCode) { + return of(this.INITIAL_TAX_VALUE); + } return from( this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress), ).pipe( @@ -355,7 +348,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked { variant: "error", message: this.i18nService.t("taxCalculationError"), }); - return of(0); // Return default value on error + return of(this.INITIAL_TAX_VALUE); // Return default value on error }), ); } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts index 2efd97b3c30..b9b2cd8de97 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.spec.ts @@ -151,14 +151,17 @@ describe("RiskInsightsEncryptionService", () => { describe("decryptRiskInsightsReport", () => { it("should decrypt data and return original object", async () => { - // Arrange: setup our mocks + // Arrange: setup our mocks with valid data structures mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); - mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); - // act: call the decrypt method - with any params - // actual decryption does not happen here, - // we just want to ensure the method calls are correct + // Mock decryption to return valid data for each call + mockEncryptService.decryptString + .mockResolvedValueOnce(JSON.stringify(mockReportData)) + .mockResolvedValueOnce(JSON.stringify(mockSummaryData)) + .mockResolvedValueOnce(JSON.stringify(mockApplicationData)); + + // act: call the decrypt method const result = await service.decryptRiskInsightsReport( { organizationId: orgId, userId }, mockEncryptedData, @@ -169,33 +172,37 @@ describe("RiskInsightsEncryptionService", () => { expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey); expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3); - // Mock decrypt returns JSON.stringify(testData) + // Verify decrypted data matches the mocked valid data expect(result).toEqual({ - reportData: testData, - summaryData: testData, - applicationData: testData, + reportData: mockReportData, + summaryData: mockSummaryData, + applicationData: mockApplicationData, }); }); it("should invoke data type validation method during decryption", async () => { - // Arrange: setup our mocks + // Arrange: setup our mocks with valid data structures mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); - mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); - // act: call the decrypt method - with any params - // actual decryption does not happen here, - // we just want to ensure the method calls are correct + // Mock decryption to return valid data for each call + mockEncryptService.decryptString + .mockResolvedValueOnce(JSON.stringify(mockReportData)) + .mockResolvedValueOnce(JSON.stringify(mockSummaryData)) + .mockResolvedValueOnce(JSON.stringify(mockApplicationData)); + + // act: call the decrypt method const result = await service.decryptRiskInsightsReport( { organizationId: orgId, userId }, mockEncryptedData, mockKey, ); + // Verify that validation passed and returned the correct data expect(result).toEqual({ - reportData: testData, - summaryData: testData, - applicationData: testData, + reportData: mockReportData, + summaryData: mockSummaryData, + applicationData: mockApplicationData, }); }); @@ -211,7 +218,7 @@ describe("RiskInsightsEncryptionService", () => { ).rejects.toEqual(Error("Organization key not found")); }); - it("should return null if decrypt throws", async () => { + it("should throw if decrypt throws", async () => { mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); @@ -224,5 +231,106 @@ describe("RiskInsightsEncryptionService", () => { ), ).rejects.toEqual(Error("fail")); }); + + it("should throw error when report data validation fails", async () => { + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); + + // Mock decryption to return invalid data + mockEncryptService.decryptString + .mockResolvedValueOnce(JSON.stringify([{ invalid: "data" }])) // invalid report data + .mockResolvedValueOnce(JSON.stringify(mockSummaryData)) + .mockResolvedValueOnce(JSON.stringify(mockApplicationData)); + + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, + ), + ).rejects.toThrow( + /Report data validation failed.*This may indicate data corruption or tampering/, + ); + }); + + it("should throw error when summary data validation fails", async () => { + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); + + // Clear and reset the mock + mockEncryptService.decryptString.mockReset(); + + // Mock decryption - report data should succeed, summary should fail + mockEncryptService.decryptString + .mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid + .mockResolvedValueOnce(JSON.stringify({ invalid: "summary" })) // invalid summary data - fails here + .mockResolvedValueOnce(JSON.stringify(mockApplicationData)); // won't be called but prevents fallback + + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, + ), + ).rejects.toThrow( + /Summary data validation failed.*This may indicate data corruption or tampering/, + ); + }); + + it("should throw error when application data validation fails", async () => { + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); + + // Clear and reset the mock + mockEncryptService.decryptString.mockReset(); + + // Mock decryption - report and summary should succeed, application should fail + mockEncryptService.decryptString + .mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid + .mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid + .mockResolvedValueOnce(JSON.stringify([{ invalid: "application" }])); // invalid app data + + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, + ), + ).rejects.toThrow( + /Application data validation failed.*This may indicate data corruption or tampering/, + ); + }); + + it("should throw error for invalid date in application data", async () => { + mockKeyService.orgKeys$.mockReturnValue(orgKey$); + mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); + + const invalidApplicationData = [ + { + applicationName: "Test App", + isCritical: true, + reviewedDate: "invalid-date-string", + }, + ]; + + // Clear and reset the mock + mockEncryptService.decryptString.mockReset(); + + // Mock decryption - report and summary succeed, application with invalid date fails + mockEncryptService.decryptString + .mockResolvedValueOnce(JSON.stringify(mockReportData)) // valid + .mockResolvedValueOnce(JSON.stringify(mockSummaryData)) // valid + .mockResolvedValueOnce(JSON.stringify(invalidApplicationData)); // invalid date + + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, + ), + ).rejects.toThrow( + /Application data validation failed.*This may indicate data corruption or tampering/, + ); + }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts index 5206cd1ecff..abeae1fdb29 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-encryption.service.ts @@ -10,14 +10,20 @@ import { LogService } from "@bitwarden/logging"; import { createNewSummaryData } from "../../helpers"; import { - DecryptedReportData, - EncryptedReportData, - EncryptedDataWithKey, ApplicationHealthReportDetail, - OrganizationReportSummary, + DecryptedReportData, + EncryptedDataWithKey, + EncryptedReportData, OrganizationReportApplication, + OrganizationReportSummary, } from "../../models"; +import { + validateApplicationHealthReportDetailArray, + validateOrganizationReportApplicationArray, + validateOrganizationReportSummary, +} from "./risk-insights-type-guards"; + export class RiskInsightsEncryptionService { constructor( private keyService: KeyService, @@ -182,11 +188,16 @@ export class RiskInsightsEncryptionService { const decryptedData = await this.encryptService.decryptString(encryptedData, key); const parsedData = JSON.parse(decryptedData); - // TODO Add type guard to check that parsed data is actual type - return parsedData as ApplicationHealthReportDetail[]; + // Validate parsed data structure with runtime type guards + return validateApplicationHealthReportDetailArray(parsedData); } catch (error: unknown) { + // Log detailed error for debugging this.logService.error("[RiskInsightsEncryptionService] Failed to decrypt report", error); - return []; + // Always throw generic message to prevent information disclosure + // Original error with detailed validation info is logged, not exposed to caller + throw new Error( + "Report data validation failed. This may indicate data corruption or tampering.", + ); } } @@ -202,14 +213,19 @@ export class RiskInsightsEncryptionService { const decryptedData = await this.encryptService.decryptString(encryptedData, key); const parsedData = JSON.parse(decryptedData); - // TODO Add type guard to check that parsed data is actual type - return parsedData as OrganizationReportSummary; + // Validate parsed data structure with runtime type guards + return validateOrganizationReportSummary(parsedData); } catch (error: unknown) { + // Log detailed error for debugging this.logService.error( "[RiskInsightsEncryptionService] Failed to decrypt report summary", error, ); - return createNewSummaryData(); + // Always throw generic message to prevent information disclosure + // Original error with detailed validation info is logged, not exposed to caller + throw new Error( + "Summary data validation failed. This may indicate data corruption or tampering.", + ); } } @@ -225,14 +241,19 @@ export class RiskInsightsEncryptionService { const decryptedData = await this.encryptService.decryptString(encryptedData, key); const parsedData = JSON.parse(decryptedData); - // TODO Add type guard to check that parsed data is actual type - return parsedData as OrganizationReportApplication[]; + // Validate parsed data structure with runtime type guards + return validateOrganizationReportApplicationArray(parsedData); } catch (error: unknown) { + // Log detailed error for debugging this.logService.error( "[RiskInsightsEncryptionService] Failed to decrypt report applications", error, ); - return []; + // Always throw generic message to prevent information disclosure + // Original error with detailed validation info is logged, not exposed to caller + throw new Error( + "Application data validation failed. This may indicate data corruption or tampering.", + ); } } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts new file mode 100644 index 00000000000..32505088818 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.spec.ts @@ -0,0 +1,668 @@ +import { MemberDetails } from "../../models"; + +import { + isApplicationHealthReportDetail, + isMemberDetails, + isOrganizationReportApplication, + isOrganizationReportSummary, + validateApplicationHealthReportDetailArray, + validateOrganizationReportApplicationArray, + validateOrganizationReportSummary, +} from "./risk-insights-type-guards"; + +describe("Risk Insights Type Guards", () => { + describe("validateApplicationHealthReportDetailArray", () => { + it("should validate valid ApplicationHealthReportDetail array", () => { + const validData = [ + { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1", "cipher-2"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [ + { + userGuid: "user-1", + userName: "John Doe", + email: "john@example.com", + cipherId: "cipher-1", + }, + ], + atRiskMemberDetails: [ + { + userGuid: "user-2", + userName: "Jane Doe", + email: "jane@example.com", + cipherId: "cipher-2", + }, + ], + cipherIds: ["cipher-1", "cipher-2"], + }, + ]; + + expect(() => validateApplicationHealthReportDetailArray(validData)).not.toThrow(); + expect(validateApplicationHealthReportDetailArray(validData)).toEqual(validData); + }); + + it("should throw error for non-array input", () => { + expect(() => validateApplicationHealthReportDetailArray("not an array")).toThrow( + "Invalid report data: expected array of ApplicationHealthReportDetail, received non-array", + ); + }); + + it("should throw error for array with invalid elements", () => { + const invalidData = [ + { + applicationName: "Test App", + // missing required fields + }, + ]; + + expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow( + /Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\) at indices: 0/, + ); + }); + + it("should throw error for array with multiple invalid elements", () => { + const invalidData = [ + { applicationName: "App 1" }, // invalid + { + applicationName: "App 2", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }, // valid + { applicationName: "App 3" }, // invalid + ]; + + expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow( + /Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\) at indices: 0, 2/, + ); + }); + + it("should throw error for invalid memberDetails", () => { + const invalidData = [ + { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [{ userGuid: "user-1" }] as any, // missing required fields + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }, + ]; + + expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow( + /Invalid report data/, + ); + }); + + it("should throw error for empty string in atRiskCipherIds", () => { + const invalidData = [ + { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1", "", "cipher-3"], // empty string + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }, + ]; + + expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow( + /Invalid report data/, + ); + }); + + it("should throw error for empty string in cipherIds", () => { + const invalidData = [ + { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["", "cipher-2"], // empty string + }, + ]; + + expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow( + /Invalid report data/, + ); + }); + }); + + describe("validateOrganizationReportSummary", () => { + it("should validate valid OrganizationReportSummary", () => { + const validData = { + totalMemberCount: 10, + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1", "app-2"], + }; + + expect(() => validateOrganizationReportSummary(validData)).not.toThrow(); + expect(validateOrganizationReportSummary(validData)).toEqual(validData); + }); + + it("should throw error for missing totalMemberCount", () => { + const invalidData = { + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1"], + }; + + expect(() => validateOrganizationReportSummary(invalidData)).toThrow( + /Invalid OrganizationReportSummary: missing or invalid fields: totalMemberCount \(number\)/, + ); + }); + + it("should throw error for multiple missing fields", () => { + const invalidData = { + totalMemberCount: 10, + // missing multiple fields + newApplications: ["app-1"], + }; + + expect(() => validateOrganizationReportSummary(invalidData)).toThrow( + /Invalid OrganizationReportSummary: missing or invalid fields:.*totalApplicationCount/, + ); + }); + + it("should throw error for invalid field types", () => { + const invalidData = { + totalMemberCount: "10", // should be number + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1"], + }; + + expect(() => validateOrganizationReportSummary(invalidData)).toThrow( + /Invalid OrganizationReportSummary/, + ); + }); + + it("should throw error for non-array newApplications", () => { + const invalidData = { + totalMemberCount: 10, + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: "not-an-array", + }; + + expect(() => validateOrganizationReportSummary(invalidData)).toThrow( + /Invalid OrganizationReportSummary.*newApplications/, + ); + }); + + it("should throw error for empty string in newApplications", () => { + const invalidData = { + totalMemberCount: 10, + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1", "", "app-3"], // empty string + }; + + expect(() => validateOrganizationReportSummary(invalidData)).toThrow( + /Invalid OrganizationReportSummary/, + ); + }); + }); + + describe("validateOrganizationReportApplicationArray", () => { + it("should validate valid OrganizationReportApplication array", () => { + const validData = [ + { + applicationName: "Test App", + isCritical: true, + reviewedDate: null, + }, + { + applicationName: "Another App", + isCritical: false, + reviewedDate: new Date("2024-01-01"), + }, + ]; + + expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow(); + const result = validateOrganizationReportApplicationArray(validData); + expect(result[0].applicationName).toBe("Test App"); + expect(result[1].reviewedDate).toBeInstanceOf(Date); + }); + + it("should convert string dates to Date objects", () => { + const validData = [ + { + applicationName: "Test App", + isCritical: true, + reviewedDate: "2024-01-01T00:00:00.000Z", + }, + ]; + + const result = validateOrganizationReportApplicationArray(validData); + expect(result[0].reviewedDate).toBeInstanceOf(Date); + expect(result[0].reviewedDate?.toISOString()).toBe("2024-01-01T00:00:00.000Z"); + }); + + it("should throw error for invalid date strings", () => { + const invalidData = [ + { + applicationName: "Test App", + isCritical: true, + reviewedDate: "invalid-date", + }, + ]; + + expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow( + "Invalid date string: invalid-date", + ); + }); + + it("should throw error for non-array input", () => { + expect(() => validateOrganizationReportApplicationArray("not an array")).toThrow( + "Invalid application data: expected array of OrganizationReportApplication, received non-array", + ); + }); + + it("should throw error for array with invalid elements", () => { + const invalidData = [ + { + applicationName: "Test App", + reviewedDate: null as any, + // missing isCritical field + } as any, + ]; + + expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow( + /Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\) at indices: 0/, + ); + }); + + it("should throw error for invalid field types", () => { + const invalidData = [ + { + applicationName: 123 as any, // should be string + isCritical: true, + reviewedDate: null as any, + } as any, + ]; + + expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow( + /Invalid application data/, + ); + }); + + it("should accept null reviewedDate", () => { + const validData = [ + { + applicationName: "Test App", + isCritical: true, + reviewedDate: null as any, + }, + ]; + + expect(() => validateOrganizationReportApplicationArray(validData)).not.toThrow(); + const result = validateOrganizationReportApplicationArray(validData); + expect(result[0].reviewedDate).toBeNull(); + }); + }); + + // Tests for exported type guard functions + describe("isMemberDetails", () => { + it("should return true for valid MemberDetails", () => { + const validData = { + userGuid: "user-1", + userName: "John Doe", + email: "john@example.com", + cipherId: "cipher-1", + }; + expect(isMemberDetails(validData)).toBe(true); + }); + + it("should return false for empty userGuid", () => { + const invalidData = { + userGuid: "", + userName: "John Doe", + email: "john@example.com", + cipherId: "cipher-1", + }; + expect(isMemberDetails(invalidData)).toBe(false); + }); + + it("should return false for empty userName", () => { + const invalidData = { + userGuid: "user-1", + userName: "", + email: "john@example.com", + cipherId: "cipher-1", + }; + expect(isMemberDetails(invalidData)).toBe(false); + }); + + it("should return false for empty email", () => { + const invalidData = { + userGuid: "user-1", + userName: "John Doe", + email: "", + cipherId: "cipher-1", + }; + expect(isMemberDetails(invalidData)).toBe(false); + }); + + it("should return false for empty cipherId", () => { + const invalidData = { + userGuid: "user-1", + userName: "John Doe", + email: "john@example.com", + cipherId: "", + }; + expect(isMemberDetails(invalidData)).toBe(false); + }); + + it("should return false for objects with unexpected properties", () => { + const invalidData = { + userGuid: "user-1", + userName: "John Doe", + email: "john@example.com", + cipherId: "cipher-1", + unexpectedProperty: "should fail", + }; + expect(isMemberDetails(invalidData)).toBe(false); + }); + + it("should return false for prototype pollution attempts", () => { + const invalidData = { + userGuid: "user-1", + userName: "John Doe", + email: "john@example.com", + cipherId: "cipher-1", + __proto__: { malicious: "payload" }, + }; + expect(isMemberDetails(invalidData)).toBe(false); + }); + }); + + describe("isApplicationHealthReportDetail", () => { + it("should return true for valid ApplicationHealthReportDetail", () => { + const validData = { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }; + expect(isApplicationHealthReportDetail(validData)).toBe(true); + }); + + it("should return false for empty applicationName", () => { + const invalidData = { + applicationName: "", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }; + expect(isApplicationHealthReportDetail(invalidData)).toBe(false); + }); + + it("should return false for NaN passwordCount", () => { + const invalidData = { + applicationName: "Test App", + passwordCount: NaN, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }; + expect(isApplicationHealthReportDetail(invalidData)).toBe(false); + }); + + it("should return false for Infinity passwordCount", () => { + const invalidData = { + applicationName: "Test App", + passwordCount: Infinity, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }; + expect(isApplicationHealthReportDetail(invalidData)).toBe(false); + }); + + it("should return false for negative passwordCount", () => { + const invalidData = { + applicationName: "Test App", + passwordCount: -5, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }; + expect(isApplicationHealthReportDetail(invalidData)).toBe(false); + }); + + it("should return false for negative memberCount", () => { + const invalidData = { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: -1, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + }; + expect(isApplicationHealthReportDetail(invalidData)).toBe(false); + }); + + it("should return false for objects with unexpected properties", () => { + const invalidData = { + applicationName: "Test App", + passwordCount: 10, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1"], + memberCount: 5, + atRiskMemberCount: 1, + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher-1"], + injectedProperty: "malicious", + }; + expect(isApplicationHealthReportDetail(invalidData)).toBe(false); + }); + }); + + describe("isOrganizationReportSummary", () => { + it("should return true for valid OrganizationReportSummary", () => { + const validData = { + totalMemberCount: 10, + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1"], + }; + expect(isOrganizationReportSummary(validData)).toBe(true); + }); + + it("should return false for NaN totalMemberCount", () => { + const invalidData = { + totalMemberCount: NaN, + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1"], + }; + expect(isOrganizationReportSummary(invalidData)).toBe(false); + }); + + it("should return false for Infinity totalApplicationCount", () => { + const invalidData = { + totalMemberCount: 10, + totalApplicationCount: Infinity, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1"], + }; + expect(isOrganizationReportSummary(invalidData)).toBe(false); + }); + + it("should return false for negative totalAtRiskMemberCount", () => { + const invalidData = { + totalMemberCount: 10, + totalApplicationCount: 5, + totalAtRiskMemberCount: -1, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1"], + }; + expect(isOrganizationReportSummary(invalidData)).toBe(false); + }); + + it("should return false for objects with unexpected properties", () => { + const invalidData = { + totalMemberCount: 10, + totalApplicationCount: 5, + totalAtRiskMemberCount: 2, + totalAtRiskApplicationCount: 1, + totalCriticalApplicationCount: 3, + totalCriticalMemberCount: 4, + totalCriticalAtRiskMemberCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: ["app-1"], + extraField: "should be rejected", + }; + expect(isOrganizationReportSummary(invalidData)).toBe(false); + }); + }); + + describe("isOrganizationReportApplication", () => { + it("should return true for valid OrganizationReportApplication", () => { + const validData = { + applicationName: "Test App", + isCritical: true, + reviewedDate: null as Date | null, + }; + expect(isOrganizationReportApplication(validData)).toBe(true); + }); + + it("should return false for empty applicationName", () => { + const invalidData = { + applicationName: "", + isCritical: true, + reviewedDate: null as Date | null, + }; + expect(isOrganizationReportApplication(invalidData)).toBe(false); + }); + + it("should return true for Date reviewedDate", () => { + const validData = { + applicationName: "Test App", + isCritical: true, + reviewedDate: new Date(), + }; + expect(isOrganizationReportApplication(validData)).toBe(true); + }); + + it("should return true for string reviewedDate", () => { + const validData = { + applicationName: "Test App", + isCritical: false, + reviewedDate: "2024-01-01", + }; + expect(isOrganizationReportApplication(validData)).toBe(true); + }); + + it("should return false for objects with unexpected properties", () => { + const invalidData = { + applicationName: "Test App", + isCritical: true, + reviewedDate: null as Date | null, + injectedProperty: "malicious", + }; + expect(isOrganizationReportApplication(invalidData)).toBe(false); + }); + + it("should return false for prototype pollution attempts via __proto__", () => { + const invalidData = { + applicationName: "Test App", + isCritical: true, + reviewedDate: null as Date | null, + __proto__: { polluted: true }, + }; + expect(isOrganizationReportApplication(invalidData)).toBe(false); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts new file mode 100644 index 00000000000..b1d2550d4fa --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-type-guards.ts @@ -0,0 +1,404 @@ +import { + ApplicationHealthReportDetail, + MemberDetails, + OrganizationReportApplication, + OrganizationReportSummary, +} from "../../models"; + +/** + * Security limits for validation (prevent DoS attacks and ensure reasonable data sizes) + */ +const MAX_STRING_LENGTH = 1000; // Reasonable limit for names, emails, GUIDs +const MAX_ARRAY_LENGTH = 50000; // Reasonable limit for report arrays +const MAX_COUNT = 10000000; // 10 million - reasonable upper bound for count fields + +/** + * Type guard to validate MemberDetails structure + * Exported for testability + * Strict validation: rejects objects with unexpected properties and prototype pollution + */ +export function isMemberDetails(obj: any): obj is MemberDetails { + if (typeof obj !== "object" || obj === null) { + return false; + } + + // Prevent prototype pollution - check prototype is Object.prototype + if (Object.getPrototypeOf(obj) !== Object.prototype) { + return false; + } + + // Prevent dangerous properties that could be used for prototype pollution + // Check for __proto__, constructor, and prototype as own properties + const dangerousKeys = ["__proto__", "constructor", "prototype"]; + for (const key of dangerousKeys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + return false; + } + } + + // Strict property validation - reject unexpected properties + const allowedKeys = ["userGuid", "userName", "email", "cipherId"]; + const actualKeys = Object.keys(obj); + const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key)); + if (hasUnexpectedProps) { + return false; + } + + return ( + typeof obj.userGuid === "string" && + obj.userGuid.length > 0 && + obj.userGuid.length <= MAX_STRING_LENGTH && + typeof obj.userName === "string" && + obj.userName.length > 0 && + obj.userName.length <= MAX_STRING_LENGTH && + typeof obj.email === "string" && + obj.email.length > 0 && + obj.email.length <= MAX_STRING_LENGTH && + typeof obj.cipherId === "string" && + obj.cipherId.length > 0 && + obj.cipherId.length <= MAX_STRING_LENGTH + ); +} + +/** + * Type guard to validate ApplicationHealthReportDetail structure + * Exported for testability + * Strict validation: rejects objects with unexpected properties and prototype pollution + */ +export function isApplicationHealthReportDetail(obj: any): obj is ApplicationHealthReportDetail { + if (typeof obj !== "object" || obj === null) { + return false; + } + + // Prevent prototype pollution - check prototype is Object.prototype + if (Object.getPrototypeOf(obj) !== Object.prototype) { + return false; + } + + // Prevent dangerous properties that could be used for prototype pollution + // Check for __proto__, constructor, and prototype as own properties + const dangerousKeys = ["__proto__", "constructor", "prototype"]; + for (const key of dangerousKeys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + return false; + } + } + + // Strict property validation - reject unexpected properties + const allowedKeys = [ + "applicationName", + "passwordCount", + "atRiskPasswordCount", + "atRiskCipherIds", + "memberCount", + "atRiskMemberCount", + "memberDetails", + "atRiskMemberDetails", + "cipherIds", + ]; + const actualKeys = Object.keys(obj); + const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key)); + if (hasUnexpectedProps) { + return false; + } + + return ( + typeof obj.applicationName === "string" && + obj.applicationName.length > 0 && + obj.applicationName.length <= MAX_STRING_LENGTH && + typeof obj.passwordCount === "number" && + Number.isFinite(obj.passwordCount) && + Number.isSafeInteger(obj.passwordCount) && + obj.passwordCount >= 0 && + obj.passwordCount <= MAX_COUNT && + typeof obj.atRiskPasswordCount === "number" && + Number.isFinite(obj.atRiskPasswordCount) && + Number.isSafeInteger(obj.atRiskPasswordCount) && + obj.atRiskPasswordCount >= 0 && + obj.atRiskPasswordCount <= MAX_COUNT && + Array.isArray(obj.atRiskCipherIds) && + obj.atRiskCipherIds.length <= MAX_ARRAY_LENGTH && + obj.atRiskCipherIds.every( + (id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH, + ) && + typeof obj.memberCount === "number" && + Number.isFinite(obj.memberCount) && + Number.isSafeInteger(obj.memberCount) && + obj.memberCount >= 0 && + obj.memberCount <= MAX_COUNT && + typeof obj.atRiskMemberCount === "number" && + Number.isFinite(obj.atRiskMemberCount) && + Number.isSafeInteger(obj.atRiskMemberCount) && + obj.atRiskMemberCount >= 0 && + obj.atRiskMemberCount <= MAX_COUNT && + Array.isArray(obj.memberDetails) && + obj.memberDetails.length <= MAX_ARRAY_LENGTH && + obj.memberDetails.every(isMemberDetails) && + Array.isArray(obj.atRiskMemberDetails) && + obj.atRiskMemberDetails.length <= MAX_ARRAY_LENGTH && + obj.atRiskMemberDetails.every(isMemberDetails) && + Array.isArray(obj.cipherIds) && + obj.cipherIds.length <= MAX_ARRAY_LENGTH && + obj.cipherIds.every( + (id: any) => typeof id === "string" && id.length > 0 && id.length <= MAX_STRING_LENGTH, + ) + ); +} + +/** + * Type guard to validate OrganizationReportSummary structure + * Exported for testability + * Strict validation: rejects objects with unexpected properties and prototype pollution + */ +export function isOrganizationReportSummary(obj: any): obj is OrganizationReportSummary { + if (typeof obj !== "object" || obj === null) { + return false; + } + + // Prevent prototype pollution - check prototype is Object.prototype + if (Object.getPrototypeOf(obj) !== Object.prototype) { + return false; + } + + // Prevent dangerous properties that could be used for prototype pollution + // Check for __proto__, constructor, and prototype as own properties + const dangerousKeys = ["__proto__", "constructor", "prototype"]; + for (const key of dangerousKeys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + return false; + } + } + + // Strict property validation - reject unexpected properties + const allowedKeys = [ + "totalMemberCount", + "totalApplicationCount", + "totalAtRiskMemberCount", + "totalAtRiskApplicationCount", + "totalCriticalApplicationCount", + "totalCriticalMemberCount", + "totalCriticalAtRiskMemberCount", + "totalCriticalAtRiskApplicationCount", + "newApplications", + ]; + const actualKeys = Object.keys(obj); + const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key)); + if (hasUnexpectedProps) { + return false; + } + + return ( + typeof obj.totalMemberCount === "number" && + Number.isFinite(obj.totalMemberCount) && + Number.isSafeInteger(obj.totalMemberCount) && + obj.totalMemberCount >= 0 && + obj.totalMemberCount <= MAX_COUNT && + typeof obj.totalApplicationCount === "number" && + Number.isFinite(obj.totalApplicationCount) && + Number.isSafeInteger(obj.totalApplicationCount) && + obj.totalApplicationCount >= 0 && + obj.totalApplicationCount <= MAX_COUNT && + typeof obj.totalAtRiskMemberCount === "number" && + Number.isFinite(obj.totalAtRiskMemberCount) && + Number.isSafeInteger(obj.totalAtRiskMemberCount) && + obj.totalAtRiskMemberCount >= 0 && + obj.totalAtRiskMemberCount <= MAX_COUNT && + typeof obj.totalAtRiskApplicationCount === "number" && + Number.isFinite(obj.totalAtRiskApplicationCount) && + Number.isSafeInteger(obj.totalAtRiskApplicationCount) && + obj.totalAtRiskApplicationCount >= 0 && + obj.totalAtRiskApplicationCount <= MAX_COUNT && + typeof obj.totalCriticalApplicationCount === "number" && + Number.isFinite(obj.totalCriticalApplicationCount) && + Number.isSafeInteger(obj.totalCriticalApplicationCount) && + obj.totalCriticalApplicationCount >= 0 && + obj.totalCriticalApplicationCount <= MAX_COUNT && + typeof obj.totalCriticalMemberCount === "number" && + Number.isFinite(obj.totalCriticalMemberCount) && + Number.isSafeInteger(obj.totalCriticalMemberCount) && + obj.totalCriticalMemberCount >= 0 && + obj.totalCriticalMemberCount <= MAX_COUNT && + typeof obj.totalCriticalAtRiskMemberCount === "number" && + Number.isFinite(obj.totalCriticalAtRiskMemberCount) && + Number.isSafeInteger(obj.totalCriticalAtRiskMemberCount) && + obj.totalCriticalAtRiskMemberCount >= 0 && + obj.totalCriticalAtRiskMemberCount <= MAX_COUNT && + typeof obj.totalCriticalAtRiskApplicationCount === "number" && + Number.isFinite(obj.totalCriticalAtRiskApplicationCount) && + Number.isSafeInteger(obj.totalCriticalAtRiskApplicationCount) && + obj.totalCriticalAtRiskApplicationCount >= 0 && + obj.totalCriticalAtRiskApplicationCount <= MAX_COUNT && + Array.isArray(obj.newApplications) && + obj.newApplications.length <= MAX_ARRAY_LENGTH && + obj.newApplications.every( + (app: any) => typeof app === "string" && app.length > 0 && app.length <= MAX_STRING_LENGTH, + ) + ); +} + +/** + * Type guard to validate OrganizationReportApplication structure + * Exported for testability + * Strict validation: rejects objects with unexpected properties and prototype pollution + */ +export function isOrganizationReportApplication(obj: any): obj is OrganizationReportApplication { + if (typeof obj !== "object" || obj === null) { + return false; + } + + // Prevent prototype pollution - check prototype is Object.prototype + if (Object.getPrototypeOf(obj) !== Object.prototype) { + return false; + } + + // Prevent dangerous properties that could be used for prototype pollution + // Check for __proto__, constructor, and prototype as own properties + const dangerousKeys = ["__proto__", "constructor", "prototype"]; + for (const key of dangerousKeys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + return false; + } + } + + // Strict property validation - reject unexpected properties + const allowedKeys = ["applicationName", "isCritical", "reviewedDate"]; + const actualKeys = Object.keys(obj); + const hasUnexpectedProps = actualKeys.some((key) => !allowedKeys.includes(key)); + if (hasUnexpectedProps) { + return false; + } + + return ( + typeof obj.applicationName === "string" && + obj.applicationName.length > 0 && + obj.applicationName.length <= MAX_STRING_LENGTH && + typeof obj.isCritical === "boolean" && + (obj.reviewedDate === null || + obj.reviewedDate instanceof Date || + typeof obj.reviewedDate === "string") + ); +} + +/** + * Validates and returns an array of ApplicationHealthReportDetail + * @throws Error if validation fails + */ +export function validateApplicationHealthReportDetailArray( + data: any, +): ApplicationHealthReportDetail[] { + if (!Array.isArray(data)) { + throw new Error( + "Invalid report data: expected array of ApplicationHealthReportDetail, received non-array", + ); + } + + if (data.length > MAX_ARRAY_LENGTH) { + throw new Error( + `Invalid report data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`, + ); + } + + const invalidItems = data + .map((item, index) => ({ item, index })) + .filter(({ item }) => !isApplicationHealthReportDetail(item)); + + if (invalidItems.length > 0) { + const invalidIndices = invalidItems.map(({ index }) => index).join(", "); + throw new Error( + `Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`, + ); + } + + return data as ApplicationHealthReportDetail[]; +} + +/** + * Validates and returns OrganizationReportSummary + * @throws Error if validation fails + */ +export function validateOrganizationReportSummary(data: any): OrganizationReportSummary { + if (!isOrganizationReportSummary(data)) { + const missingFields: string[] = []; + + if (typeof data?.totalMemberCount !== "number") { + missingFields.push("totalMemberCount (number)"); + } + if (typeof data?.totalApplicationCount !== "number") { + missingFields.push("totalApplicationCount (number)"); + } + if (typeof data?.totalAtRiskMemberCount !== "number") { + missingFields.push("totalAtRiskMemberCount (number)"); + } + if (typeof data?.totalAtRiskApplicationCount !== "number") { + missingFields.push("totalAtRiskApplicationCount (number)"); + } + if (typeof data?.totalCriticalApplicationCount !== "number") { + missingFields.push("totalCriticalApplicationCount (number)"); + } + if (typeof data?.totalCriticalMemberCount !== "number") { + missingFields.push("totalCriticalMemberCount (number)"); + } + if (typeof data?.totalCriticalAtRiskMemberCount !== "number") { + missingFields.push("totalCriticalAtRiskMemberCount (number)"); + } + if (typeof data?.totalCriticalAtRiskApplicationCount !== "number") { + missingFields.push("totalCriticalAtRiskApplicationCount (number)"); + } + if (!Array.isArray(data?.newApplications)) { + missingFields.push("newApplications (string[])"); + } + + throw new Error( + `Invalid OrganizationReportSummary: ${missingFields.length > 0 ? `missing or invalid fields: ${missingFields.join(", ")}` : "structure validation failed"}`, + ); + } + + return data as OrganizationReportSummary; +} + +/** + * Validates and returns an array of OrganizationReportApplication + * @throws Error if validation fails + */ +export function validateOrganizationReportApplicationArray( + data: any, +): OrganizationReportApplication[] { + if (!Array.isArray(data)) { + throw new Error( + "Invalid application data: expected array of OrganizationReportApplication, received non-array", + ); + } + + if (data.length > MAX_ARRAY_LENGTH) { + throw new Error( + `Invalid application data: array length ${data.length} exceeds maximum allowed length ${MAX_ARRAY_LENGTH}`, + ); + } + + const invalidItems = data + .map((item, index) => ({ item, index })) + .filter(({ item }) => !isOrganizationReportApplication(item)); + + if (invalidItems.length > 0) { + const invalidIndices = invalidItems.map(({ index }) => index).join(", "); + throw new Error( + `Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`, + ); + } + + // Convert string dates to Date objects for reviewedDate + return data.map((item) => ({ + ...item, + reviewedDate: item.reviewedDate + ? item.reviewedDate instanceof Date + ? item.reviewedDate + : (() => { + const date = new Date(item.reviewedDate); + if (isNaN(date.getTime())) { + throw new Error(`Invalid date string: ${item.reviewedDate}`); + } + return date; + })() + : null, + })) as OrganizationReportApplication[]; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts index 89f120cbded..6855274498a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -175,6 +175,65 @@ export class RiskInsightsDataService { } }; + setDrawerForCriticalAtRiskMembers = async (invokerId: string = ""): Promise => { + const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; + const shouldClose = + open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId; + + if (shouldClose) { + this.closeDrawer(); + } else { + const reportResults = await firstValueFrom(this.criticalReportResults$); + if (!reportResults?.reportData) { + return; + } + + // Generate at-risk member list from critical applications + const atRiskMemberDetails = getAtRiskMemberList(reportResults.reportData); + + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails, + appAtRiskMembers: null, + atRiskAppDetails: null, + }); + } + }; + + setDrawerForCriticalAtRiskApps = async (invokerId: string = ""): Promise => { + const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; + const shouldClose = + open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId; + + if (shouldClose) { + this.closeDrawer(); + } else { + const reportResults = await firstValueFrom(this.criticalReportResults$); + if (!reportResults?.reportData) { + return; + } + + // Filter critical applications for those with at-risk passwords + const criticalAtRiskApps = reportResults.reportData + .filter((app) => app.atRiskPasswordCount > 0) + .map((app) => ({ + applicationName: app.applicationName, + atRiskPasswordCount: app.atRiskPasswordCount, + })); + + this.drawerDetailsSubject.next({ + open: true, + invokerId, + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: criticalAtRiskApps, + }); + } + }; + // ------------------------------ Critical application methods -------------- saveCriticalApplications(selectedUrls: string[]) { return this.orchestrator.saveCriticalApplications$(selectedUrls); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html index 0eb9b30367c..756907d24e6 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html @@ -23,11 +23,11 @@ } - @if (showNavigationLink && !buttonText) { + @if (showActionLink && !buttonText) {

diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts index 7abedb06a7c..84c763841b5 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts @@ -37,25 +37,14 @@ export class ActivityCardComponent { @Input() metricDescription: string = ""; /** - * The link to navigate to for more information + * The text to display for the action link */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() navigationLink: string = ""; + @Input() actionText: string = ""; /** - * The text to display for the navigation link + * Show action link */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() navigationText: string = ""; - - /** - * Show Navigation link - */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() showNavigationLink: boolean = false; + @Input() showActionLink: boolean = false; /** * Icon class to display next to metrics (e.g., "bwi-exclamation-triangle"). @@ -86,13 +75,18 @@ export class ActivityCardComponent { // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() buttonClick = new EventEmitter(); - constructor(private router: Router) {} + /** + * Event emitted when action link is clicked + */ + @Output() actionClick = new EventEmitter(); - navigateToLink = async (navigationLink: string) => { - await this.router.navigateByUrl(navigationLink); - }; + constructor(private router: Router) {} onButtonClick = () => { this.buttonClick.emit(); }; + + onActionClick = () => { + this.actionClick.emit(); + }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index 844b2f92bb3..9fffded215e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -13,9 +13,9 @@ [title]="'atRiskMembers' | i18n" [cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount" [metricDescription]="'membersWithAccessToAtRiskItemsForCriticalApps' | i18n" - navigationText="{{ 'viewAtRiskMembers' | i18n }}" - navigationLink="{{ getLinkForRiskInsightsTab(RiskInsightsTabType.AllApps) }}" - [showNavigationLink]="totalCriticalAppsAtRiskMemberCount > 0" + actionText="{{ 'viewAtRiskMembers' | i18n }}" + [showActionLink]="totalCriticalAppsAtRiskMemberCount > 0" + (actionClick)="onViewAtRiskMembers()" > @@ -35,9 +35,9 @@ : ('criticalApplicationsAreAtRisk' | i18n: totalCriticalAppsAtRiskCount : totalCriticalAppsCount) " - navigationText="{{ 'viewAtRiskApplications' | i18n }}" - navigationLink="{{ getLinkForRiskInsightsTab(RiskInsightsTabType.CriticalApps) }}" - [showNavigationLink]="totalCriticalAppsAtRiskCount > 0" + actionText="{{ 'viewAtRiskApplications' | i18n }}" + [showActionLink]="totalCriticalAppsAtRiskCount > 0" + (actionClick)="onViewAtRiskApplications()" > diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 947e2f2fa42..9689110866a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -15,7 +15,6 @@ import { getById } from "@bitwarden/common/platform/misc"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { RiskInsightsTabType } from "../models/risk-insights.models"; import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component"; import { ActivityCardComponent } from "./activity-card.component"; @@ -82,15 +81,6 @@ export class AllActivityComponent implements OnInit { } } - get RiskInsightsTabType() { - return RiskInsightsTabType; - } - - getLinkForRiskInsightsTab(tabIndex: RiskInsightsTabType): string { - const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); - return `/organizations/${organizationId}/access-intelligence/risk-insights?tabIndex=${tabIndex}`; - } - /** * Handles the review new applications button click. * Opens a dialog showing the list of new applications that can be marked as critical. @@ -102,4 +92,20 @@ export class AllActivityComponent implements OnInit { await firstValueFrom(dialogRef.closed); }; + + /** + * Handles the "View at-risk members" link click. + * Opens the at-risk members drawer for critical applications only. + */ + onViewAtRiskMembers = async () => { + await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers"); + }; + + /** + * Handles the "View at-risk applications" link click. + * Opens the at-risk applications drawer for critical applications only. + */ + onViewAtRiskApplications = async () => { + await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications"); + }; } diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts b/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts new file mode 100644 index 00000000000..e753184273e --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { UserId } from "@bitwarden/user-core"; + +import { AutoConfirmState } from "../models/auto-confirm-state.model"; + +export abstract class AutomaticUserConfirmationService { + /** + * @param userId + * @returns Observable an observable with the Auto Confirm user state for the provided userId. + **/ + abstract configuration$(userId: UserId): Observable; + /** + * Upserts the existing user state with a new configuration. + * @param userId + * @param config The new AutoConfirmState to upsert into the user state for the provided userId. + **/ + abstract upsert(userId: UserId, config: AutoConfirmState): Promise; + /** + * This will check if the feature is enabled, the organization plan feature UseAutomaticUserConfirmation is enabled + * and the the provided user has admin/owner/manage custom permission role. + * @param userId + * @returns Observable an observable with a boolean telling us if the provided user may confgure the auto confirm feature. + **/ + abstract canManageAutoConfirm$( + userId: UserId, + organizationId: OrganizationId, + ): Observable; + /** + * Calls the API endpoint to initiate automatic user confirmation. + * @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks. + * @param confirmingUserId The userId of the user being confirmed. + * @param organization the organization the user is being auto confirmed to. + **/ + abstract autoConfirmUser( + userId: UserId, + confirmingUserId: UserId, + organization: Organization, + ): Promise; +} diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/index.ts b/libs/admin-console/src/common/auto-confirm/abstractions/index.ts new file mode 100644 index 00000000000..87e284656ab --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/abstractions/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm.service.abstraction"; diff --git a/libs/admin-console/src/common/auto-confirm/index.ts b/libs/admin-console/src/common/auto-confirm/index.ts new file mode 100644 index 00000000000..9187ccd39cf --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/index.ts @@ -0,0 +1,3 @@ +export * from "./abstractions"; +export * from "./models"; +export * from "./services"; diff --git a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts similarity index 84% rename from libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts rename to libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts index b97f980b644..c69db69746c 100644 --- a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts +++ b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts @@ -1,4 +1,4 @@ -import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state"; +import { AUTO_CONFIRM, UserKeyDefinition } from "@bitwarden/state"; export class AutoConfirmState { enabled: boolean; diff --git a/libs/admin-console/src/common/auto-confirm/models/index.ts b/libs/admin-console/src/common/auto-confirm/models/index.ts new file mode 100644 index 00000000000..a34c54c16aa --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/models/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm-state.model"; diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts new file mode 100644 index 00000000000..133dac758b4 --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts @@ -0,0 +1,382 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject, firstValueFrom, of, throwError } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; + +import { + DefaultOrganizationUserService, + OrganizationUserApiService, + OrganizationUserConfirmRequest, +} from "../../organization-user"; +import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; + +import { DefaultAutomaticUserConfirmationService } from "./default-auto-confirm.service"; + +describe("DefaultAutomaticUserConfirmationService", () => { + let service: DefaultAutomaticUserConfirmationService; + let configService: jest.Mocked; + let apiService: jest.Mocked; + let organizationUserService: jest.Mocked; + let stateProvider: FakeStateProvider; + let organizationService: jest.Mocked; + let organizationUserApiService: jest.Mocked; + + const mockUserId = Utils.newGuid() as UserId; + const mockConfirmingUserId = Utils.newGuid() as UserId; + const mockOrganizationId = Utils.newGuid() as OrganizationId; + let mockOrganization: Organization; + + beforeEach(() => { + configService = { + getFeatureFlag$: jest.fn(), + } as any; + + apiService = { + getUserPublicKey: jest.fn(), + } as any; + + organizationUserService = { + buildConfirmRequest: jest.fn(), + } as any; + + stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId)); + + organizationService = { + organizations$: jest.fn(), + } as any; + + organizationUserApiService = { + postOrganizationUserConfirm: jest.fn(), + } as any; + + TestBed.configureTestingModule({ + providers: [ + DefaultAutomaticUserConfirmationService, + { provide: ConfigService, useValue: configService }, + { provide: ApiService, useValue: apiService }, + { provide: DefaultOrganizationUserService, useValue: organizationUserService }, + { provide: "StateProvider", useValue: stateProvider }, + { + provide: InternalOrganizationServiceAbstraction, + useValue: organizationService, + }, + { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + ], + }); + + service = new DefaultAutomaticUserConfirmationService( + configService, + apiService, + organizationUserService, + stateProvider, + organizationService, + organizationUserApiService, + ); + + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + + mockOrganization = new Organization(mockOrgData); + }); + + describe("configuration$", () => { + it("should return default AutoConfirmState when no state exists", async () => { + const config$ = service.configuration$(mockUserId); + const config = await firstValueFrom(config$); + + expect(config).toBeInstanceOf(AutoConfirmState); + expect(config.enabled).toBe(false); + expect(config.showSetupDialog).toBe(true); + }); + + it("should return stored AutoConfirmState when state exists", async () => { + const expectedConfig = new AutoConfirmState(); + expectedConfig.enabled = true; + expectedConfig.showSetupDialog = false; + expectedConfig.showBrowserNotification = true; + + await stateProvider.setUserState( + AUTO_CONFIRM_STATE, + { [mockUserId]: expectedConfig }, + mockUserId, + ); + + const config$ = service.configuration$(mockUserId); + const config = await firstValueFrom(config$); + + expect(config.enabled).toBe(true); + expect(config.showSetupDialog).toBe(false); + expect(config.showBrowserNotification).toBe(true); + }); + + it("should emit updates when state changes", async () => { + const config$ = service.configuration$(mockUserId); + const configs: AutoConfirmState[] = []; + + const subscription = config$.subscribe((config) => configs.push(config)); + + expect(configs[0].enabled).toBe(false); + + const newConfig = new AutoConfirmState(); + newConfig.enabled = true; + await stateProvider.setUserState(AUTO_CONFIRM_STATE, { [mockUserId]: newConfig }, mockUserId); + + expect(configs.length).toBeGreaterThan(1); + expect(configs[configs.length - 1].enabled).toBe(true); + + subscription.unsubscribe(); + }); + }); + + describe("upsert", () => { + it("should store new configuration for user", async () => { + const newConfig = new AutoConfirmState(); + newConfig.enabled = true; + newConfig.showSetupDialog = false; + + await service.upsert(mockUserId, newConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId]).toEqual(newConfig); + }); + + it("should update existing configuration for user", async () => { + const initialConfig = new AutoConfirmState(); + initialConfig.enabled = false; + + await service.upsert(mockUserId, initialConfig); + + const updatedConfig = new AutoConfirmState(); + updatedConfig.enabled = true; + updatedConfig.showSetupDialog = false; + + await service.upsert(mockUserId, updatedConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId].enabled).toBe(true); + expect(storedState![mockUserId].showSetupDialog).toBe(false); + }); + + it("should preserve other user configurations when updating", async () => { + const otherUserId = Utils.newGuid() as UserId; + const otherConfig = new AutoConfirmState(); + otherConfig.enabled = true; + + await stateProvider.setUserState( + AUTO_CONFIRM_STATE, + { [otherUserId]: otherConfig }, + mockUserId, + ); + + const newConfig = new AutoConfirmState(); + newConfig.enabled = false; + + await service.upsert(mockUserId, newConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId]).toEqual(newConfig); + expect(storedState![otherUserId]).toEqual(otherConfig); + }); + }); + + describe("canManageAutoConfirm$", () => { + beforeEach(() => { + const organizations$ = new BehaviorSubject([mockOrganization]); + organizationService.organizations$.mockReturnValue(organizations$); + }); + + it("should return true when feature flag is enabled and organization allows management", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(true); + }); + + it("should return false when feature flag is disabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization canManageUsers is false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization without manageUsers permission + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + const permissions = new PermissionsApi(); + permissions.manageUsers = false; + mockOrgData.permissions = permissions; + const orgWithoutManageUsers = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWithoutManageUsers]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization useAutomaticUserConfirmation is false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization without useAutomaticUserConfirmation + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = false; + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + const orgWithoutAutoConfirm = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWithoutAutoConfirm]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization is not found", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const organizations$ = new BehaviorSubject([]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should use the correct feature flag", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + await firstValueFrom(canManage$); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm); + }); + }); + + describe("autoConfirmUser", () => { + const mockPublicKey = "mock-public-key-base64"; + const mockPublicKeyArray = new Uint8Array([1, 2, 3, 4]); + const mockConfirmRequest = { + key: "encrypted-key", + defaultUserCollectionName: "encrypted-collection", + } as OrganizationUserConfirmRequest; + + beforeEach(() => { + const organizations$ = new BehaviorSubject([mockOrganization]); + organizationService.organizations$.mockReturnValue(organizations$); + configService.getFeatureFlag$.mockReturnValue(of(true)); + + apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any); + jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray); + organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest)); + organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined); + }); + + it("should successfully auto-confirm a user", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(apiService.getUserPublicKey).toHaveBeenCalledWith(mockUserId); + expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith( + mockOrganization, + mockPublicKeyArray, + ); + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + mockOrganizationId, + mockConfirmingUserId, + mockConfirmRequest, + ); + }); + + it("should not confirm user when canManageAutoConfirm returns false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("Cannot automatically confirm user (insufficient permissions)"); + + expect(apiService.getUserPublicKey).not.toHaveBeenCalled(); + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + + it("should build confirm request with organization and public key", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith( + mockOrganization, + mockPublicKeyArray, + ); + }); + + it("should call API with correct parameters", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + mockOrganization.id, + mockConfirmingUserId, + mockConfirmRequest, + ); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error"); + apiService.getUserPublicKey.mockRejectedValue(apiError); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("API Error"); + + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + + it("should handle buildConfirmRequest errors gracefully", async () => { + const buildError = new Error("Build Error"); + organizationUserService.buildConfirmRequest.mockReturnValue(throwError(() => buildError)); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("Build Error"); + + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts new file mode 100644 index 00000000000..a906a2ddc4a --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts @@ -0,0 +1,90 @@ +import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { StateProvider } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +import { + DefaultOrganizationUserService, + OrganizationUserApiService, +} from "../../organization-user"; +import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction"; +import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; + +export class DefaultAutomaticUserConfirmationService implements AutomaticUserConfirmationService { + constructor( + private configService: ConfigService, + private apiService: ApiService, + private organizationUserService: DefaultOrganizationUserService, + private stateProvider: StateProvider, + private organizationService: InternalOrganizationServiceAbstraction, + private organizationUserApiService: OrganizationUserApiService, + ) {} + private autoConfirmState(userId: UserId) { + return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE); + } + + configuration$(userId: UserId): Observable { + return this.autoConfirmState(userId).state$.pipe( + map((records) => records?.[userId] ?? new AutoConfirmState()), + ); + } + + async upsert(userId: UserId, config: AutoConfirmState): Promise { + await this.autoConfirmState(userId).update((records) => { + return { + ...records, + [userId]: config, + }; + }); + } + + canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable { + return combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + this.organizationService.organizations$(userId).pipe(getById(organizationId)), + ]).pipe( + map( + ([enabled, organization]) => + (enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ?? + false, + ), + ); + } + + async autoConfirmUser( + userId: UserId, + confirmingUserId: UserId, + organization: Organization, + ): Promise { + await firstValueFrom( + this.canManageAutoConfirm$(userId, organization.id).pipe( + map((canManage) => { + if (!canManage) { + throw new Error("Cannot automatically confirm user (insufficient permissions)"); + } + return canManage; + }), + switchMap(() => this.apiService.getUserPublicKey(userId)), + map((publicKeyResponse) => Utils.fromB64ToArray(publicKeyResponse.publicKey)), + switchMap((publicKey) => + this.organizationUserService.buildConfirmRequest(organization, publicKey), + ), + switchMap((request) => + this.organizationUserApiService.postOrganizationUserConfirm( + organization.id, + confirmingUserId, + request, + ), + ), + ), + ); + } +} diff --git a/libs/admin-console/src/common/auto-confirm/services/index.ts b/libs/admin-console/src/common/auto-confirm/services/index.ts new file mode 100644 index 00000000000..305ae380848 --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/index.ts @@ -0,0 +1 @@ +export * from "./default-auto-confirm.service"; diff --git a/libs/admin-console/src/common/index.ts b/libs/admin-console/src/common/index.ts index edeff5aa314..37f79d56256 100644 --- a/libs/admin-console/src/common/index.ts +++ b/libs/admin-console/src/common/index.ts @@ -1,2 +1,3 @@ -export * from "./organization-user"; +export * from "./auto-confirm"; export * from "./collections"; +export * from "./organization-user"; diff --git a/libs/admin-console/src/common/organization-user/abstractions/index.ts b/libs/admin-console/src/common/organization-user/abstractions/index.ts index 01cd189b3dd..dc2788deead 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/index.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/index.ts @@ -1 +1,2 @@ export * from "./organization-user-api.service"; +export * from "./organization-user.service"; diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts index ff422231a12..71d228ff822 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts @@ -148,6 +148,19 @@ export abstract class OrganizationUserApiService { request: OrganizationUserConfirmRequest, ): Promise; + /** + * Admin api for automatically confirming an organization user that + * has accepted their invitation + * @param organizationId - Identifier for the organization to confirm + * @param id - Organization user identifier + * @param request - Request details for confirming the user + */ + abstract postOrganizationUserAutoConfirm( + organizationId: string, + id: string, + request: OrganizationUserConfirmRequest, + ): Promise; + /** * Retrieve a list of the specified users' public keys * @param organizationId - Identifier for the organization to accept diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts new file mode 100644 index 00000000000..844a0f412be --- /dev/null +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts @@ -0,0 +1,45 @@ +import { Observable } from "rxjs"; + +import { + OrganizationUserConfirmRequest, + OrganizationUserBulkResponse, +} from "@bitwarden/admin-console/common"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +export abstract class OrganizationUserService { + /** + * Builds a confirmation request for an organization user. + * @param organization - The organization the user belongs to + * @param publicKey - The user's public key + * @returns An observable that emits the confirmation request + */ + abstract buildConfirmRequest( + organization: Organization, + publicKey: Uint8Array, + ): Observable; + + /** + * Confirms a user in an organization. + * @param organization - The organization the user belongs to + * @param userId - The ID of the user to confirm + * @param publicKey - The user's public key + * @returns An observable that completes when the user is confirmed + */ + abstract confirmUser( + organization: Organization, + userId: string, + publicKey: Uint8Array, + ): Observable; + + /** + * Confirms multiple users in an organization. + * @param organization - The organization the users belong to + * @param userIdsWithKeys - Array of user IDs with their encrypted keys + * @returns An observable that emits the bulk confirmation response + */ + abstract bulkConfirmUsers( + organization: Organization, + userIdsWithKeys: { id: string; key: string }[], + ): Observable>; +} diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts index c16fba258ec..869d84a8c8e 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts @@ -194,6 +194,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer ); } + postOrganizationUserAutoConfirm( + organizationId: string, + id: string, + request: OrganizationUserConfirmRequest, + ): Promise { + return this.apiService.send( + "POST", + "/organizations/" + organizationId + "/users/" + id + "/auto-confirm", + request, + true, + false, + ); + } + async postOrganizationUsersPublicKey( organizationId: string, ids: string[], diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts similarity index 91% rename from apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts rename to libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts index 2ae5aa4eb98..982fb3ca5e0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts @@ -19,12 +19,10 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { DefaultOrganizationUserService } from "./default-organization-user.service"; -import { OrganizationUserService } from "./organization-user.service"; - -describe("OrganizationUserService", () => { - let service: OrganizationUserService; +describe("DefaultOrganizationUserService", () => { + let service: DefaultOrganizationUserService; let keyService: jest.Mocked; let encryptService: jest.Mocked; let organizationUserApiService: jest.Mocked; @@ -34,9 +32,7 @@ describe("OrganizationUserService", () => { const mockOrganization = new Organization(); mockOrganization.id = "org-123" as OrganizationId; - const mockOrganizationUser = new OrganizationUserView(); - mockOrganizationUser.id = "user-123"; - + const mockUserId = "user-123"; const mockPublicKey = new Uint8Array(64) as CsprngArray; const mockRandomBytes = new Uint8Array(64) as CsprngArray; const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; @@ -77,7 +73,7 @@ describe("OrganizationUserService", () => { TestBed.configureTestingModule({ providers: [ - OrganizationUserService, + DefaultOrganizationUserService, { provide: KeyService, useValue: keyService }, { provide: EncryptService, useValue: encryptService }, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, @@ -86,7 +82,13 @@ describe("OrganizationUserService", () => { ], }); - service = TestBed.inject(OrganizationUserService); + service = new DefaultOrganizationUserService( + keyService, + encryptService, + organizationUserApiService, + accountService, + i18nService, + ); }); describe("confirmUser", () => { @@ -97,7 +99,7 @@ describe("OrganizationUserService", () => { }); it("should confirm a user successfully", (done) => { - service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({ + service.confirmUser(mockOrganization, mockUserId, mockPublicKey).subscribe({ next: () => { expect(i18nService.t).toHaveBeenCalledWith("myItems"); @@ -112,7 +114,7 @@ describe("OrganizationUserService", () => { expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( mockOrganization.id, - mockOrganizationUser.id, + mockUserId, { key: mockEncryptedKey.encryptedString, defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts similarity index 80% rename from apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts rename to libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts index f59b377e26e..4f503a92675 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts @@ -1,4 +1,3 @@ -import { Injectable } from "@angular/core"; import { combineLatest, filter, map, Observable, switchMap } from "rxjs"; import { @@ -6,6 +5,7 @@ import { OrganizationUserBulkConfirmRequest, OrganizationUserApiService, OrganizationUserBulkResponse, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,12 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { OrganizationId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserView } from "../../../core/views/organization-user.view"; - -@Injectable({ - providedIn: "root", -}) -export class OrganizationUserService { +export class DefaultOrganizationUserService implements OrganizationUserService { constructor( protected keyService: KeyService, private encryptService: EncryptService, @@ -39,11 +34,10 @@ export class OrganizationUserService { ); } - confirmUser( + buildConfirmRequest( organization: Organization, - user: OrganizationUserView, publicKey: Uint8Array, - ): Observable { + ): Observable { const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization); const encryptedKey$ = this.orgKey$(organization).pipe( @@ -51,18 +45,22 @@ export class OrganizationUserService { ); return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe( - switchMap(([key, collectionName]) => { - const request: OrganizationUserConfirmRequest = { - key: key.encryptedString, - defaultUserCollectionName: collectionName.encryptedString, - }; + map(([key, collectionName]) => ({ + key: key.encryptedString, + defaultUserCollectionName: collectionName.encryptedString, + })), + ); + } - return this.organizationUserApiService.postOrganizationUserConfirm( + confirmUser(organization: Organization, userId: string, publicKey: Uint8Array): Observable { + return this.buildConfirmRequest(organization, publicKey).pipe( + switchMap((request) => + this.organizationUserApiService.postOrganizationUserConfirm( organization.id, - user.id, + userId, request, - ); - }), + ), + ), ); } diff --git a/libs/admin-console/src/common/organization-user/services/index.ts b/libs/admin-console/src/common/organization-user/services/index.ts index 6135236d6a6..929a9fcd39a 100644 --- a/libs/admin-console/src/common/organization-user/services/index.ts +++ b/libs/admin-console/src/common/organization-user/services/index.ts @@ -1 +1,2 @@ export * from "./default-organization-user-api.service"; +export * from "./default-organization-user.service"; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index 11c6cddcab1..5f1da4a1cd8 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -1,5 +1,6 @@ import { CurrencyPipe } from "@angular/common"; import { Component, computed, input, signal } from "@angular/core"; +import { toObservable } from "@angular/core/rxjs-interop"; import { TypographyModule, IconButtonModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -71,6 +72,11 @@ export class CartSummaryComponent { */ readonly total = computed(() => this.getTotalCost()); + /** + * Observable of computed total value + */ + readonly total$ = toObservable(this.total); + /** * Toggles the expanded/collapsed state of the cart items */