diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html index 0e757582855..04c7bd23797 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html @@ -22,7 +22,7 @@ type="button" class="tw-flex-1" tabindex="0" - (click)="dataService.setDrawerForOrgAtRiskMembers('criticalAppsAtRiskMembers')" + (click)="dataService.setDrawerForCriticalAtRiskMembers('criticalAppsAtRiskMembers')" > @if (drawerDetails.atRiskMemberDetails?.length > 0) { +
@@ -77,6 +86,15 @@ }} @if (drawerDetails.atRiskAppDetails?.length > 0) { +
{{ "application" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts index 2b5910ed99e..9066462b2b1 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts @@ -3,8 +3,10 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DIALOG_DATA } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; import { I18nPipe } from "@bitwarden/ui-common"; import { RiskInsightsDrawerDialogComponent } from "./risk-insights-drawer-dialog.component"; @@ -48,6 +50,8 @@ describe("RiskInsightsDrawerDialogComponent", () => { let component: RiskInsightsDrawerDialogComponent; let fixture: ComponentFixture; const mockI18nService = mock(); + const mockFileDownloadService = mock(); + const mocklogService = mock(); const drawerDetails: DrawerDetails = { open: true, invokerId: "test-invoker", @@ -56,6 +60,7 @@ describe("RiskInsightsDrawerDialogComponent", () => { appAtRiskMembers: null, atRiskAppDetails: null, }; + mockI18nService.t.mockImplementation((key: string) => key); beforeEach(async () => { await TestBed.configureTestingModule({ @@ -64,6 +69,8 @@ describe("RiskInsightsDrawerDialogComponent", () => { { provide: DIALOG_DATA, useValue: drawerDetails }, { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + { provide: LogService, useValue: mocklogService }, ], }).compileComponents(); @@ -93,4 +100,181 @@ describe("RiskInsightsDrawerDialogComponent", () => { expect(component.isActiveDrawerType(DrawerType.AppAtRiskMembers)).toBeFalsy(); }); }); + describe("downloadAtRiskMembers", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should download CSV when drawer is open with correct type and has data", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [ + { email: "user@example.com", atRiskPasswordCount: 5 }, + { email: "admin@example.com", atRiskPasswordCount: 3 }, + ], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + mockI18nService.t.mockImplementation((key: string) => key); + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("at-risk-members"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should not download when drawer is closed", async () => { + component.drawerDetails = { + open: false, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [{ email: "user@example.com", atRiskPasswordCount: 5 }], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when activeDrawerType is incorrect", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [{ email: "user@example.com", atRiskPasswordCount: 5 }], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskMemberDetails is null", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskMemberDetails is empty array", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + }); + + describe("downloadAtRiskApplications", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should download CSV when drawer is open with correct type and has data", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [ + { applicationName: "App1", atRiskPasswordCount: 10 }, + { applicationName: "App2", atRiskPasswordCount: 7 }, + ], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("at-risk-applications"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should not download when drawer is closed", async () => { + component.drawerDetails = { + open: false, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [{ applicationName: "App1", atRiskPasswordCount: 10 }], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when activeDrawerType is incorrect", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [{ applicationName: "App1", atRiskPasswordCount: 10 }], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskAppDetails is null", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskAppDetails is empty array", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts index 82cddda542c..30863f38e43 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts @@ -1,7 +1,12 @@ import { Component, ChangeDetectionStrategy, Inject } from "@angular/core"; import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DIALOG_DATA } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { ExportHelper } from "@bitwarden/vault-export-core"; +import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @Component({ @@ -10,7 +15,12 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RiskInsightsDrawerDialogComponent { - constructor(@Inject(DIALOG_DATA) public drawerDetails: DrawerDetails) {} + constructor( + @Inject(DIALOG_DATA) public drawerDetails: DrawerDetails, + private fileDownloadService: FileDownloadService, + private i18nService: I18nService, + private logService: LogService, + ) {} // Get a list of drawer types get drawerTypes(): typeof DrawerType { @@ -20,4 +30,62 @@ export class RiskInsightsDrawerDialogComponent { isActiveDrawerType(type: DrawerType): boolean { return this.drawerDetails.activeDrawerType === type; } + + /** + * downloads at risk members as CSV + */ + downloadAtRiskMembers() { + try { + // Validate drawer is open and showing the correct drawer type + if ( + !this.drawerDetails.open || + this.drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskMembers || + !this.drawerDetails.atRiskMemberDetails || + this.drawerDetails.atRiskMemberDetails.length === 0 + ) { + return; + } + + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("at-risk-members"), + blobData: exportToCSV(this.drawerDetails.atRiskMemberDetails, { + email: this.i18nService.t("email"), + atRiskPasswordCount: this.i18nService.t("atRiskPasswords"), + }), + blobOptions: { type: "text/plain" }, + }); + } catch (error) { + // Log error for debugging + this.logService.error("Failed to download at-risk members", error); + } + } + + /** + * downloads at risk applications as CSV + */ + downloadAtRiskApplications() { + try { + // Validate drawer is open and showing the correct drawer type + if ( + !this.drawerDetails.open || + this.drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskApps || + !this.drawerDetails.atRiskAppDetails || + this.drawerDetails.atRiskAppDetails.length === 0 + ) { + return; + } + + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("at-risk-applications"), + blobData: exportToCSV(this.drawerDetails.atRiskAppDetails, { + applicationName: this.i18nService.t("application"), + atRiskPasswordCount: this.i18nService.t("atRiskPasswords"), + }), + blobOptions: { type: "text/plain" }, + }); + } catch (error) { + // Log error for debugging + this.logService.error("Failed to download at-risk applications", error); + } + } }