mirror of
https://github.com/bitwarden/browser
synced 2026-02-08 12:40:26 +00:00
Merge branch 'main' into km/encstring-remove-decrypt
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<EncryptService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountService: FakeAccountService;
|
||||
let billingConstraintService: MockProxy<BillingConstraintService>;
|
||||
let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
|
||||
|
||||
const userId = newGuid() as UserId;
|
||||
const organizationId = newGuid() as OrganizationId;
|
||||
@@ -50,7 +50,7 @@ describe("MemberActionsService", () => {
|
||||
encryptService = mock<EncryptService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
billingConstraintService = mock<BillingConstraintService>();
|
||||
organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
|
||||
|
||||
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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<DialogService>();
|
||||
const mockOrganizationService = mock<OrganizationService>();
|
||||
const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
|
||||
const mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
|
||||
/**
|
||||
* 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<Account | null>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> = 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<boolean> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<billing-cart-summary
|
||||
#cartSummaryComponent
|
||||
[passwordManager]="passwordManager"
|
||||
[estimatedTax]="estimatedTax"
|
||||
[estimatedTax]="estimatedTax$ | async"
|
||||
></billing-cart-summary>
|
||||
@if (isFamiliesPlan) {
|
||||
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
|
||||
|
||||
@@ -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<PersonalSubscriptionPricingTierId>();
|
||||
protected readonly account = input.required<Account>();
|
||||
protected goBack = output<void>();
|
||||
@@ -104,12 +107,8 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
|
||||
protected selectedPlan: PlanDetails | null = null;
|
||||
protected hasEnoughAccountCredit$!: Observable<boolean>;
|
||||
|
||||
// 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<string>("", [Validators.required]),
|
||||
@@ -118,12 +117,11 @@ export class UpgradePaymentComponent implements OnInit, AfterViewChecked {
|
||||
});
|
||||
|
||||
protected readonly loading = signal(true);
|
||||
private cartSummaryConfigured = false;
|
||||
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
|
||||
|
||||
// Cart Summary data
|
||||
protected passwordManager!: LineItem;
|
||||
protected estimatedTax = 0;
|
||||
protected estimatedTax$!: Observable<number>;
|
||||
|
||||
// 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<UpgradePaymentResult> {
|
||||
@@ -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<number> {
|
||||
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
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -175,6 +175,65 @@ export class RiskInsightsDataService {
|
||||
}
|
||||
};
|
||||
|
||||
setDrawerForCriticalAtRiskMembers = async (invokerId: string = ""): Promise<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (showNavigationLink && !buttonText) {
|
||||
@if (showActionLink && !buttonText) {
|
||||
<div class="tw-flex tw-items-baseline tw-mt-4 tw-gap-2">
|
||||
<p bitTypography="body1">
|
||||
<a bitLink (click)="navigateToLink(navigationLink)" rel="noreferrer">
|
||||
{{ navigationText }}
|
||||
<a bitLink href="#" (click)="onActionClick(); $event.preventDefault()" rel="noreferrer">
|
||||
{{ actionText }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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<void>();
|
||||
|
||||
constructor(private router: Router) {}
|
||||
/**
|
||||
* Event emitted when action link is clicked
|
||||
*/
|
||||
@Output() actionClick = new EventEmitter<void>();
|
||||
|
||||
navigateToLink = async (navigationLink: string) => {
|
||||
await this.router.navigateByUrl(navigationLink);
|
||||
};
|
||||
constructor(private router: Router) {}
|
||||
|
||||
onButtonClick = () => {
|
||||
this.buttonClick.emit();
|
||||
};
|
||||
|
||||
onActionClick = () => {
|
||||
this.actionClick.emit();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
@@ -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()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<AutoConfirmState> an observable with the Auto Confirm user state for the provided userId.
|
||||
**/
|
||||
abstract configuration$(userId: UserId): Observable<AutoConfirmState>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<boolean> an observable with a boolean telling us if the provided user may confgure the auto confirm feature.
|
||||
**/
|
||||
abstract canManageAutoConfirm$(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Observable<boolean>;
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./auto-confirm.service.abstraction";
|
||||
3
libs/admin-console/src/common/auto-confirm/index.ts
Normal file
3
libs/admin-console/src/common/auto-confirm/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state";
|
||||
import { AUTO_CONFIRM, UserKeyDefinition } from "@bitwarden/state";
|
||||
|
||||
export class AutoConfirmState {
|
||||
enabled: boolean;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./auto-confirm-state.model";
|
||||
@@ -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<ConfigService>;
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
let organizationUserService: jest.Mocked<DefaultOrganizationUserService>;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let organizationService: jest.Mocked<InternalOrganizationServiceAbstraction>;
|
||||
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
|
||||
|
||||
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<Organization[]>([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<Organization[]>([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<Organization[]>([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<Organization[]>([]);
|
||||
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<Organization[]>([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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AutoConfirmState> {
|
||||
return this.autoConfirmState(userId).state$.pipe(
|
||||
map((records) => records?.[userId] ?? new AutoConfirmState()),
|
||||
);
|
||||
}
|
||||
|
||||
async upsert(userId: UserId, config: AutoConfirmState): Promise<void> {
|
||||
await this.autoConfirmState(userId).update((records) => {
|
||||
return {
|
||||
...records,
|
||||
[userId]: config,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./default-auto-confirm.service";
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./organization-user";
|
||||
export * from "./auto-confirm";
|
||||
export * from "./collections";
|
||||
export * from "./organization-user";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./organization-user-api.service";
|
||||
export * from "./organization-user.service";
|
||||
|
||||
@@ -148,6 +148,19 @@ export abstract class OrganizationUserApiService {
|
||||
request: OrganizationUserConfirmRequest,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Retrieve a list of the specified users' public keys
|
||||
* @param organizationId - Identifier for the organization to accept
|
||||
|
||||
@@ -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<OrganizationUserConfirmRequest>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<ListResponse<OrganizationUserBulkResponse>>;
|
||||
}
|
||||
@@ -194,6 +194,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
|
||||
);
|
||||
}
|
||||
|
||||
postOrganizationUserAutoConfirm(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
request: OrganizationUserConfirmRequest,
|
||||
): Promise<void> {
|
||||
return this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/" + organizationId + "/users/" + id + "/auto-confirm",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async postOrganizationUsersPublicKey(
|
||||
organizationId: string,
|
||||
ids: string[],
|
||||
|
||||
@@ -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<KeyService>;
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
|
||||
@@ -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,
|
||||
@@ -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<void> {
|
||||
): Observable<OrganizationUserConfirmRequest> {
|
||||
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<void> {
|
||||
return this.buildConfirmRequest(organization, publicKey).pipe(
|
||||
switchMap((request) =>
|
||||
this.organizationUserApiService.postOrganizationUserConfirm(
|
||||
organization.id,
|
||||
user.id,
|
||||
userId,
|
||||
request,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./default-organization-user-api.service";
|
||||
export * from "./default-organization-user.service";
|
||||
|
||||
@@ -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<number>(() => this.getTotalCost());
|
||||
|
||||
/**
|
||||
* Observable of computed total value
|
||||
*/
|
||||
readonly total$ = toObservable(this.total);
|
||||
|
||||
/**
|
||||
* Toggles the expanded/collapsed state of the cart items
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user