mirror of
https://github.com/bitwarden/browser
synced 2026-02-07 12:13:45 +00:00
pm-31420 Add download button to export Access Intelligence table into csv report (#18802)
* pm-31420 add download button feature to new applications tab for access intelligence feature * PM-31420 fixing unit tests * pm-31420 adding types * pm-31420 fixing types and merging in main
This commit is contained in:
@@ -33,6 +33,15 @@
|
||||
<i class="bwi tw-mr-2" [ngClass]="selectedUrls().size ? 'bwi-star-f' : 'bwi-star'"></i>
|
||||
{{ "markAppAsCritical" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
[label]="'downloadCSV' | i18n"
|
||||
[disabled]="!dataSource.filteredData.length"
|
||||
(click)="downloadApplicationsCSV()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<app-table-row-scrollable-m11
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
DrawerDetails,
|
||||
DrawerType,
|
||||
MemberDetails,
|
||||
ReportStatus,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { RiskInsightsEnrichedData } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-data-service.types";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { TableDataSource, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component";
|
||||
|
||||
import { ApplicationsComponent } from "./applications.component";
|
||||
|
||||
// Helper type to access protected members in tests
|
||||
type ComponentWithProtectedMembers = ApplicationsComponent & {
|
||||
dataSource: TableDataSource<ApplicationTableDataSource>;
|
||||
};
|
||||
|
||||
describe("ApplicationsComponent", () => {
|
||||
let component: ApplicationsComponent;
|
||||
let fixture: ComponentFixture<ApplicationsComponent>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockFileDownloadService: MockProxy<FileDownloadService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockDataService: MockProxy<RiskInsightsDataService>;
|
||||
|
||||
const reportStatus$ = new BehaviorSubject<ReportStatus>(ReportStatus.Complete);
|
||||
const enrichedReportData$ = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
|
||||
const criticalReportResults$ = new BehaviorSubject<RiskInsightsEnrichedData | null>(null);
|
||||
const drawerDetails$ = new BehaviorSubject<DrawerDetails>({
|
||||
open: false,
|
||||
invokerId: "",
|
||||
activeDrawerType: DrawerType.None,
|
||||
atRiskMemberDetails: [],
|
||||
appAtRiskMembers: null,
|
||||
atRiskAppDetails: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockFileDownloadService = mock<FileDownloadService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockDataService = mock<RiskInsightsDataService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
Object.defineProperty(mockDataService, "reportStatus$", { get: () => reportStatus$ });
|
||||
Object.defineProperty(mockDataService, "enrichedReportData$", {
|
||||
get: () => enrichedReportData$,
|
||||
});
|
||||
Object.defineProperty(mockDataService, "criticalReportResults$", {
|
||||
get: () => criticalReportResults$,
|
||||
});
|
||||
Object.defineProperty(mockDataService, "drawerDetails$", { get: () => drawerDetails$ });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ApplicationsComponent, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: FileDownloadService, useValue: mockFileDownloadService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: RiskInsightsDataService, useValue: mockDataService },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: (): string | null => null } } },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ApplicationsComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("downloadApplicationsCSV", () => {
|
||||
const mockApplicationData: ApplicationTableDataSource[] = [
|
||||
{
|
||||
applicationName: "GitHub",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 3,
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 2,
|
||||
isMarkedAsCritical: true,
|
||||
atRiskCipherIds: ["cipher1" as CipherId],
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher1" as CipherId],
|
||||
iconCipher: undefined,
|
||||
},
|
||||
{
|
||||
applicationName: "Slack",
|
||||
passwordCount: 8,
|
||||
atRiskPasswordCount: 1,
|
||||
memberCount: 4,
|
||||
atRiskMemberCount: 1,
|
||||
isMarkedAsCritical: false,
|
||||
atRiskCipherIds: ["cipher2" as CipherId],
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher2" as CipherId],
|
||||
iconCipher: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should download CSV with correct data when filteredData has items", () => {
|
||||
// Set up the data source with mock data
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledTimes(1);
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledWith({
|
||||
fileName: expect.stringContaining("applications"),
|
||||
blobData: expect.any(String),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not download when filteredData is empty", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [];
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockFileDownloadService.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use translated column headers in CSV", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("application");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("atRiskPasswords");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("totalPasswords");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("atRiskMembers");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("totalMembers");
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("criticalBadge");
|
||||
});
|
||||
|
||||
it("should translate isMarkedAsCritical to 'yes' when true", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[0]]; // Critical app
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("yes");
|
||||
});
|
||||
|
||||
it("should translate isMarkedAsCritical to 'no' when false", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[1]]; // Non-critical app
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("no");
|
||||
});
|
||||
|
||||
it("should include correct application data in CSV export", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[0]];
|
||||
|
||||
let capturedBlobData: string = "";
|
||||
mockFileDownloadService.download.mockImplementation((options) => {
|
||||
capturedBlobData = options.blobData as string;
|
||||
});
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
// Verify the CSV contains the application data
|
||||
expect(capturedBlobData).toContain("GitHub");
|
||||
expect(capturedBlobData).toContain("10"); // passwordCount
|
||||
expect(capturedBlobData).toContain("3"); // atRiskPasswordCount
|
||||
expect(capturedBlobData).toContain("5"); // memberCount
|
||||
expect(capturedBlobData).toContain("2"); // atRiskMemberCount
|
||||
});
|
||||
|
||||
it("should log error when download fails", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
|
||||
const testError = new Error("Download failed");
|
||||
mockFileDownloadService.download.mockImplementation(() => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"Failed to download applications CSV",
|
||||
testError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should only export filtered data when filter is applied", () => {
|
||||
(component as ComponentWithProtectedMembers).dataSource = new TableDataSource();
|
||||
(component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData;
|
||||
// Apply a filter that only matches "GitHub"
|
||||
(component as ComponentWithProtectedMembers).dataSource.filter = (
|
||||
app: (typeof mockApplicationData)[0],
|
||||
) => app.applicationName === "GitHub";
|
||||
|
||||
let capturedBlobData: string = "";
|
||||
mockFileDownloadService.download.mockImplementation((options) => {
|
||||
capturedBlobData = options.blobData as string;
|
||||
});
|
||||
|
||||
component.downloadApplicationsCSV();
|
||||
|
||||
// Verify only GitHub is in the export (not Slack)
|
||||
expect(capturedBlobData).toContain("GitHub");
|
||||
expect(capturedBlobData).not.toContain("Slack");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
OrganizationReportSummary,
|
||||
ReportStatus,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
TypographyModule,
|
||||
ChipSelectComponent,
|
||||
} from "@bitwarden/components";
|
||||
import { ExportHelper } from "@bitwarden/vault-export-core";
|
||||
import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
@@ -70,6 +74,8 @@ export type ApplicationFilterOption =
|
||||
})
|
||||
export class ApplicationsComponent implements OnInit {
|
||||
destroyRef = inject(DestroyRef);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
private logService = inject(LogService);
|
||||
|
||||
protected ReportStatusEnum = ReportStatus;
|
||||
protected noItemsIcon = Security;
|
||||
@@ -225,4 +231,39 @@ export class ApplicationsComponent implements OnInit {
|
||||
return nextSelected;
|
||||
});
|
||||
};
|
||||
|
||||
downloadApplicationsCSV = () => {
|
||||
try {
|
||||
const data = this.dataSource.filteredData;
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exportData = data.map((app) => ({
|
||||
applicationName: app.applicationName,
|
||||
atRiskPasswordCount: app.atRiskPasswordCount,
|
||||
passwordCount: app.passwordCount,
|
||||
atRiskMemberCount: app.atRiskMemberCount,
|
||||
memberCount: app.memberCount,
|
||||
isMarkedAsCritical: app.isMarkedAsCritical
|
||||
? this.i18nService.t("yes")
|
||||
: this.i18nService.t("no"),
|
||||
}));
|
||||
|
||||
this.fileDownloadService.download({
|
||||
fileName: ExportHelper.getFileName("applications"),
|
||||
blobData: exportToCSV(exportData, {
|
||||
applicationName: this.i18nService.t("application"),
|
||||
atRiskPasswordCount: this.i18nService.t("atRiskPasswords"),
|
||||
passwordCount: this.i18nService.t("totalPasswords"),
|
||||
atRiskMemberCount: this.i18nService.t("atRiskMembers"),
|
||||
memberCount: this.i18nService.t("totalMembers"),
|
||||
isMarkedAsCritical: this.i18nService.t("criticalBadge"),
|
||||
}),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to download applications CSV", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user