From 268b290cd119f48a9f6005cc909e68e832ed76db Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 9 Dec 2025 15:38:43 -0500 Subject: [PATCH] continue refactor, add tests --- .../bulk/bulk-confirm-dialog.component.ts | 1 + .../bulk/bulk-delete-dialog.component.ts | 1 + .../bulk/bulk-enable-sm-dialog.component.ts | 1 + .../bulk/bulk-remove-dialog.component.ts | 1 + .../bulk/bulk-restore-revoke.component.ts | 2 +- .../components/bulk/bulk-status.component.ts | 2 +- .../members/members.component.spec.ts | 644 ++++++++++++++++++ .../members/members.component.ts | 87 +-- .../dialogs/bulk-confirm-dialog.component.ts | 1 + .../dialogs/bulk-remove-dialog.component.ts | 1 + 10 files changed, 682 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/members.component.spec.ts 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 3a624e11d95..fd3803d1a0e 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 @@ -39,6 +39,7 @@ type BulkConfirmDialogParams = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "bulk-confirm-dialog.component.html", + selector: "member-bulk-comfirm-dialog", standalone: false, }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts index 0fd60b859f0..87e44355688 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.ts @@ -20,6 +20,7 @@ type BulkDeleteDialogParams = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "bulk-delete-dialog.component.html", + selector: "member-bulk-delete-dialog", standalone: false, }) export class BulkDeleteDialogComponent { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts index a97d595e443..5f457b4eaee 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-enable-sm-dialog.component.ts @@ -24,6 +24,7 @@ export type BulkEnableSecretsManagerDialogData = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: `bulk-enable-sm-dialog.component.html`, + selector: "member-bulk-enable-sm-dialog", standalone: false, }) export class BulkEnableSecretsManagerDialogComponent implements OnInit { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts index 7c95e43c8cf..f5628860d69 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.ts @@ -23,6 +23,7 @@ type BulkRemoveDialogParams = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "bulk-remove-dialog.component.html", + selector: "member-bulk-remove-dialog", standalone: false, }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts index 5e542de907a..dc7b079fefe 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts @@ -18,7 +18,7 @@ type BulkRestoreDialogParams = { // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-bulk-restore-revoke", + selector: "member-bulk-restore-revoke", templateUrl: "bulk-restore-revoke.component.html", standalone: false, }) diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts index 4f2456e1dc6..5c9bf919ed4 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-status.component.ts @@ -41,7 +41,7 @@ type BulkStatusDialogData = { // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-bulk-status", + selector: "member-bulk-status", templateUrl: "bulk-status.component.html", standalone: false, }) diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts new file mode 100644 index 00000000000..cbbf634d1a7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts @@ -0,0 +1,644 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + OrganizationUserStatusType, + OrganizationUserType, +} from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +import { OrganizationUserView } from "../core/views/organization-user.view"; + +import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; +import { MemberDialogResult } from "./components/member-dialog"; +import { MembersComponent } from "./members.component"; +import { MemberDialogManagerService, OrganizationMembersService } from "./services"; +import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; +import { + MemberActionsService, + MemberActionResult, +} from "./services/member-actions/member-actions.service"; + +describe("MembersComponent", () => { + let component: MembersComponent; + let fixture: ComponentFixture; + + let mockApiService: MockProxy; + let mockI18nService: MockProxy; + let mockOrganizationManagementPreferencesService: MockProxy; + let mockKeyService: MockProxy; + let mockValidationService: MockProxy; + let mockLogService: MockProxy; + let mockUserNamePipe: MockProxy; + let mockDialogService: MockProxy; + let mockToastService: MockProxy; + let mockActivatedRoute: ActivatedRoute; + let mockDeleteManagedMemberWarningService: MockProxy; + let mockOrganizationWarningsService: MockProxy; + let mockMemberActionsService: MockProxy; + let mockMemberDialogManager: MockProxy; + let mockBillingConstraint: MockProxy; + let mockMemberService: MockProxy; + let mockOrganizationService: MockProxy; + let mockAccountService: FakeAccountService; + let mockPolicyService: MockProxy; + let mockPolicyApiService: MockProxy; + let mockOrganizationMetadataService: MockProxy; + + let routeParamsSubject: BehaviorSubject; + let queryParamsSubject: BehaviorSubject; + + const mockUserId = newGuid() as UserId; + const mockOrgId = newGuid() as OrganizationId; + const mockOrg = { + id: mockOrgId, + name: "Test Organization", + enabled: true, + canManageUsers: true, + useSecretsManager: true, + useResetPassword: true, + isProviderUser: false, + } as Organization; + + const mockUser = { + id: newGuid(), + userId: newGuid(), + type: OrganizationUserType.User, + status: OrganizationUserStatusType.Confirmed, + email: "test@example.com", + name: "Test User", + resetPasswordEnrolled: false, + accessSecretsManager: false, + managedByOrganization: false, + twoFactorEnabled: false, + usesKeyConnector: false, + hasMasterPassword: true, + } as OrganizationUserView; + + const mockBillingMetadata = { + isSubscriptionUnpaid: false, + } as Partial; + + beforeEach(async () => { + routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId }); + queryParamsSubject = new BehaviorSubject({}); + + mockActivatedRoute = { + params: routeParamsSubject.asObservable(), + queryParams: queryParamsSubject.asObservable(), + } as any; + + mockApiService = mock(); + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key: string) => key); + + mockOrganizationManagementPreferencesService = mock(); + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints = { + state$: of(false), + } as any; + + mockKeyService = mock(); + mockValidationService = mock(); + mockLogService = mock(); + mockUserNamePipe = mock(); + mockUserNamePipe.transform.mockReturnValue("Test User"); + + mockDialogService = mock(); + mockToastService = mock(); + mockDeleteManagedMemberWarningService = mock(); + mockOrganizationWarningsService = mock(); + mockMemberActionsService = mock(); + mockMemberDialogManager = mock(); + mockBillingConstraint = mock(); + + mockMemberService = mock(); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + mockOrganizationService = mock(); + mockOrganizationService.organizations$.mockReturnValue(of([mockOrg])); + + mockAccountService = mockAccountServiceWith(mockUserId); + + mockPolicyService = mock(); + + mockPolicyApiService = mock(); + mockOrganizationMetadataService = mock(); + mockOrganizationMetadataService.getOrganizationMetadata$.mockReturnValue( + of(mockBillingMetadata), + ); + + await TestBed.configureTestingModule({ + declarations: [MembersComponent], + providers: [ + { provide: ApiService, useValue: mockApiService }, + { provide: I18nService, useValue: mockI18nService }, + { + provide: OrganizationManagementPreferencesService, + useValue: mockOrganizationManagementPreferencesService, + }, + { provide: KeyService, useValue: mockKeyService }, + { provide: ValidationService, useValue: mockValidationService }, + { provide: LogService, useValue: mockLogService }, + { provide: UserNamePipe, useValue: mockUserNamePipe }, + { provide: DialogService, useValue: mockDialogService }, + { provide: ToastService, useValue: mockToastService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { + provide: DeleteManagedMemberWarningService, + useValue: mockDeleteManagedMemberWarningService, + }, + { provide: OrganizationWarningsService, useValue: mockOrganizationWarningsService }, + { provide: MemberActionsService, useValue: mockMemberActionsService }, + { provide: MemberDialogManagerService, useValue: mockMemberDialogManager }, + { provide: BillingConstraintService, useValue: mockBillingConstraint }, + { provide: OrganizationMembersService, useValue: mockMemberService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { + provide: OrganizationMetadataServiceAbstraction, + useValue: mockOrganizationMetadataService, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(MembersComponent, { + remove: { imports: [] }, + add: { template: "
" }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(MembersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + jest.restoreAllMocks(); + }); + + describe("load", () => { + it("should load users and set data source", async () => { + const users = [mockUser]; + mockMemberService.loadUsers.mockResolvedValue(users); + + await component.load(mockOrg); + + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + expect(component["dataSource"].data).toEqual(users); + expect(component["firstLoaded"]()).toBe(true); + }); + + it("should handle empty response", async () => { + mockMemberService.loadUsers.mockResolvedValue([]); + + await component.load(mockOrg); + + expect(component["dataSource"].data).toEqual([]); + }); + }); + + describe("remove", () => { + it("should remove user when confirmed", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.removeUser.mockResolvedValue({ success: true }); + + const removeSpy = jest.spyOn(component["dataSource"], "removeUser"); + + await component.remove(mockUser, mockOrg); + + expect(mockMemberDialogManager.openRemoveUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + ); + expect(mockMemberActionsService.removeUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(removeSpy).toHaveBeenCalledWith(mockUser); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not remove user when not confirmed", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.remove(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.removeUser).not.toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.removeUser.mockResolvedValue({ + success: false, + error: "Remove failed", + }); + + await component.remove(mockUser, mockOrg); + + expect(mockValidationService.showError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe("reinvite", () => { + it("should reinvite user successfully", async () => { + mockMemberActionsService.reinviteUser.mockResolvedValue({ success: true }); + + await component.reinvite(mockUser, mockOrg); + + expect(mockMemberActionsService.reinviteUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberActionsService.reinviteUser.mockResolvedValue({ + success: false, + error: "Reinvite failed", + }); + + await component.reinvite(mockUser, mockOrg); + + expect(mockValidationService.showError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe("confirm", () => { + const mockPublicKeyResponse = { + publicKey: "AQIDBA==", + } as UserKeyResponse; + + beforeEach(() => { + mockApiService.getUserPublicKey.mockResolvedValue(mockPublicKeyResponse); + mockKeyService.getFingerprint.mockResolvedValue(["fingerprint"]); + }); + + it("should confirm user with auto-confirm enabled", async () => { + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true); + mockMemberActionsService.confirmUser.mockResolvedValue({ success: true }); + + await component.confirm(mockUser, mockOrg); + + expect(mockApiService.getUserPublicKey).toHaveBeenCalledWith(mockUser.userId); + expect(mockMemberActionsService.confirmUser).toHaveBeenCalledWith( + mockUser, + expect.any(Uint8Array), + mockOrg, + ); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should handle null user", async () => { + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true); + + await component.confirm(null as any, mockOrg); + + expect(mockMemberActionsService.confirmUser).not.toHaveBeenCalled(); + }); + + it("should handle API errors gracefully", async () => { + const error = new Error("API error"); + mockApiService.getUserPublicKey.mockRejectedValue(error); + + await component.confirm(mockUser, mockOrg); + + expect(mockLogService.error).toHaveBeenCalled(); + }); + }); + + describe("revoke", () => { + it("should revoke user when confirmed", async () => { + mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.revokeUser.mockResolvedValue({ success: true }); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.revoke(mockUser, mockOrg); + + expect(mockMemberDialogManager.openRevokeUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + ); + expect(mockMemberActionsService.revokeUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not revoke user when not confirmed", async () => { + mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.revoke(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.revokeUser).not.toHaveBeenCalled(); + }); + }); + + describe("restore", () => { + it("should restore user successfully", async () => { + mockMemberActionsService.restoreUser.mockResolvedValue({ success: true }); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.restore(mockUser, mockOrg); + + expect(mockMemberActionsService.restoreUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberActionsService.restoreUser.mockResolvedValue({ + success: false, + error: "Restore failed", + }); + + await component.restore(mockUser, mockOrg); + + expect(mockValidationService.showError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe("invite", () => { + it("should open invite dialog when seat limit not reached", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(false); + mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved); + + await component.invite(mockOrg); + + expect(mockBillingConstraint.checkSeatLimit).toHaveBeenCalledWith( + mockOrg, + mockBillingMetadata, + ); + expect(mockMemberDialogManager.openInviteDialog).toHaveBeenCalledWith( + mockOrg, + mockBillingMetadata, + expect.any(Array), + ); + }); + + it("should reload organization and refresh metadata cache after successful invite", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(false); + mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.invite(mockOrg); + + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled(); + }); + + it("should not open dialog when seat limit reached", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(true); + + await component.invite(mockOrg); + + expect(mockMemberDialogManager.openInviteDialog).not.toHaveBeenCalled(); + }); + }); + + describe("bulkRemove", () => { + it("should open bulk remove dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkRemove(mockOrg); + + expect(mockMemberDialogManager.openBulkRemoveDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkDelete", () => { + it("should open bulk delete dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkDelete(mockOrg); + + expect(mockMemberDialogManager.openBulkDeleteDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkRevokeOrRestore", () => { + it.each([ + { isRevoking: true, action: "revoke" }, + { isRevoking: false, action: "restore" }, + ])( + "should open bulk $action dialog and reload when isRevoking is $isRevoking", + async ({ isRevoking }) => { + const users = [mockUser]; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkRevokeOrRestore(isRevoking, mockOrg); + + expect(mockMemberDialogManager.openBulkRestoreRevokeDialog).toHaveBeenCalledWith( + mockOrg, + users, + isRevoking, + ); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }, + ); + }); + + describe("bulkReinvite", () => { + it("should reinvite invited users", async () => { + const invitedUser = { + ...mockUser, + status: OrganizationUserStatusType.Invited, + }; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue([invitedUser]); + mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: true }); + + await component.bulkReinvite(mockOrg); + + expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]); + expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled(); + }); + + it("should show error when no invited users selected", async () => { + const confirmedUser = { + ...mockUser, + status: OrganizationUserStatusType.Confirmed, + }; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue([confirmedUser]); + + await component.bulkReinvite(mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "errorOccurred", + message: "noSelectedUsersApplicable", + }); + expect(mockMemberActionsService.bulkReinvite).not.toHaveBeenCalled(); + }); + + it("should handle errors", async () => { + const invitedUser = { + ...mockUser, + status: OrganizationUserStatusType.Invited, + }; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue([invitedUser]); + const error = new Error("Bulk reinvite failed"); + mockMemberActionsService.bulkReinvite.mockRejectedValue(error); + + await component.bulkReinvite(mockOrg); + + expect(mockValidationService.showError).toHaveBeenCalledWith(error); + }); + }); + + describe("bulkConfirm", () => { + it("should open bulk confirm dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkConfirm(mockOrg); + + expect(mockMemberDialogManager.openBulkConfirmDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkEnableSM", () => { + it("should open bulk enable SM dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"], "getCheckedUsers").mockReturnValue(users); + jest.spyOn(component["dataSource"], "uncheckAllUsers"); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkEnableSM(mockOrg); + + expect(mockMemberDialogManager.openBulkEnableSecretsManagerDialog).toHaveBeenCalledWith( + mockOrg, + users, + ); + expect(component["dataSource"].uncheckAllUsers).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("resetPassword", () => { + it("should open account recovery dialog", async () => { + mockMemberDialogManager.openAccountRecoveryDialog.mockResolvedValue( + AccountRecoveryDialogResultType.Ok, + ); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.resetPassword(mockUser, mockOrg); + + expect(mockMemberDialogManager.openAccountRecoveryDialog).toHaveBeenCalledWith( + mockUser, + mockOrg, + ); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("deleteUser", () => { + it("should delete user when confirmed", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.deleteUser.mockResolvedValue({ success: true }); + const removeSpy = jest.spyOn(component["dataSource"], "removeUser"); + + await component.deleteUser(mockUser, mockOrg); + + expect(mockMemberDialogManager.openDeleteUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + mockOrg, + ); + expect(mockMemberActionsService.deleteUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(removeSpy).toHaveBeenCalledWith(mockUser); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not delete user when not confirmed", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.deleteUser(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.deleteUser).not.toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.deleteUser.mockResolvedValue({ + success: false, + error: "Delete failed", + }); + + await component.deleteUser(mockUser, mockOrg); + + expect(mockValidationService.showError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe("handleMemberActionResult", () => { + it("should show success toast when result is successful", async () => { + const result: MemberActionResult = { success: true }; + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "testSuccessKey", + }); + }); + + it("should execute side effect when provided and successful", async () => { + const result: MemberActionResult = { success: true }; + const sideEffect = jest.fn(); + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect); + + expect(sideEffect).toHaveBeenCalled(); + }); + + it("should call validationService.showError when result is not successful", async () => { + const result: MemberActionResult = { success: false, error: "Error message" }; + const sideEffect = jest.fn(); + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect); + + expect(mockValidationService.showError).toHaveBeenCalledWith(expect.any(Error)); + expect(sideEffect).not.toHaveBeenCalled(); + }); + + it("should call validationService.showError when side effect throws", async () => { + const result: MemberActionResult = { success: true }; + const error = new Error("Side effect failed"); + const sideEffect = jest.fn().mockRejectedValue(error); + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect); + + expect(mockValidationService.showError).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index ed4962b4a0f..be7e5397953 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -234,22 +234,14 @@ export class MembersComponent { return false; } - try { - const result = await this.memberActionsService.removeUser(organization, user.id); - const sideEffect = () => this.dataSource.removeUser(user); - await this.handleMemberActionResult(result, "removedUserId", user, sideEffect); - } catch (e) { - this.validationService.showError(e); - } + const result = await this.memberActionsService.removeUser(organization, user.id); + const sideEffect = () => this.dataSource.removeUser(user); + await this.handleMemberActionResult(result, "removedUserId", user, sideEffect); } async reinvite(user: OrganizationUserView, organization: Organization) { - try { - const result = await this.memberActionsService.reinviteUser(organization, user.id); - await this.handleMemberActionResult(result, "hasBeenReinvited", user); - } catch (e) { - this.validationService.showError(e); - } + const result = await this.memberActionsService.reinviteUser(organization, user.id); + await this.handleMemberActionResult(result, "hasBeenReinvited", user); } async confirm(user: OrganizationUserView, organization: Organization) { @@ -259,18 +251,8 @@ export class MembersComponent { }; const confirmUser = async (publicKey: Uint8Array) => { - try { - const result = await this.memberActionsService.confirmUser(user, publicKey, organization); - await this.handleMemberActionResult( - result, - "hasBeenConfirmed", - user, - confirmUserSideEffect, - ); - } catch (e) { - this.validationService.showError(e); - throw e; - } + const result = await this.memberActionsService.confirmUser(user, publicKey, organization); + await this.handleMemberActionResult(result, "hasBeenConfirmed", user, confirmUserSideEffect); }; try { @@ -316,23 +298,15 @@ export class MembersComponent { return false; } - try { - const result = await this.memberActionsService.revokeUser(organization, user.id); - const sideEffect = async () => await this.load(organization); - await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect); - } catch (e) { - this.validationService.showError(e); - } + const result = await this.memberActionsService.revokeUser(organization, user.id); + const sideEffect = async () => await this.load(organization); + await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect); } async restore(user: OrganizationUserView, organization: Organization) { - try { - const result = await this.memberActionsService.restoreUser(organization, user.id); - const sideEffect = async () => await this.load(organization); - await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect); - } catch (e) { - this.validationService.showError(e); - } + const result = await this.memberActionsService.restoreUser(organization, user.id); + const sideEffect = async () => await this.load(organization); + await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect); } allowResetPassword( @@ -458,7 +432,6 @@ export class MembersComponent { throw new Error(); } - // Bulk Status component open await this.memberDialogManager.openBulkStatusDialog( users, filteredUsers, @@ -520,14 +493,10 @@ export class MembersComponent { return false; } - try { - const result = await this.memberActionsService.deleteUser(organization, user.id); - await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => { - this.dataSource.removeUser(user); - }); - } catch (e) { - this.validationService.showError(e); - } + const result = await this.memberActionsService.deleteUser(organization, user.id); + await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => { + this.dataSource.removeUser(user); + }); } async handleMemberActionResult( @@ -536,16 +505,20 @@ export class MembersComponent { user: OrganizationUserView, sideEffect?: () => void | Promise, ) { - if (result.success) { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t(successKey, this.userNamePipe.transform(user)), - }); - if (sideEffect) { - await sideEffect(); + try { + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t(successKey, this.userNamePipe.transform(user)), + }); + if (sideEffect) { + await sideEffect(); + } + } else { + throw new Error(result.error); } - } else { - throw new Error(result.error); + } catch (e) { + this.validationService.showError(e); } } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index 7ade77ed01b..84bd6988f0b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -36,6 +36,7 @@ type BulkConfirmDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html", + selector: "provider-bulk-comfirm-dialog", standalone: false, }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts index 29b50f71c1b..c044b9379c5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts @@ -21,6 +21,7 @@ type BulkRemoveDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html", + selector: "provider-bulk-remove-dialog", standalone: false, }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent {