1
0
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:
Graham Walker
2026-02-06 10:15:07 -06:00
committed by GitHub
parent 9bdfc68aa2
commit 6b071481e2
3 changed files with 292 additions and 0 deletions

View File

@@ -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

View File

@@ -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");
});
});
});

View File

@@ -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);
}
};
}