diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html
index 81304855c8c..765985d43b3 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html
@@ -33,6 +33,15 @@
{{ "markAppAsCritical" | i18n }}
+
+
;
+};
+
+describe("ApplicationsComponent", () => {
+ let component: ApplicationsComponent;
+ let fixture: ComponentFixture;
+ let mockI18nService: MockProxy;
+ let mockFileDownloadService: MockProxy;
+ let mockLogService: MockProxy;
+ let mockToastService: MockProxy;
+ let mockDataService: MockProxy;
+
+ const reportStatus$ = new BehaviorSubject(ReportStatus.Complete);
+ const enrichedReportData$ = new BehaviorSubject(null);
+ const criticalReportResults$ = new BehaviorSubject(null);
+ const drawerDetails$ = new BehaviorSubject({
+ open: false,
+ invokerId: "",
+ activeDrawerType: DrawerType.None,
+ atRiskMemberDetails: [],
+ appAtRiskMembers: null,
+ atRiskAppDetails: null,
+ });
+
+ beforeEach(async () => {
+ mockI18nService = mock();
+ mockFileDownloadService = mock();
+ mockLogService = mock();
+ mockToastService = mock();
+ mockDataService = mock();
+
+ 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");
+ });
+ });
+});
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
index 8cd0c2640f5..4f8b1eb34f2 100644
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts
@@ -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);
+ }
+ };
}