From e784622f67dc6a3f312c86cca63c768ce4f0fd35 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:37:23 -0500 Subject: [PATCH] [PM-25613] Add report trigger logic (#16615) * Add password trigger logic to report service. Also updated api to use classes that properly handle encstring with placeholders for upcoming usage * Fix merged test case conflict * Fix type errors and test cases. Make create data functions for report and summary --- .../helpers/risk-insights-data-mappers.ts | 37 ++- .../src/dirt/reports/risk-insights/index.ts | 1 + .../risk-insights/models/api-models.types.ts | 75 ++++- .../reports/risk-insights/models/index.ts | 3 + .../risk-insights/models/password-health.ts | 11 +- .../risk-insights/models/report-models.ts | 7 - .../services/password-health.service.ts | 1 + .../risk-insights-api.service.spec.ts | 299 +++++++++--------- .../services/risk-insights-api.service.ts | 47 +-- .../services/risk-insights-data.service.ts | 133 +++++++- .../risk-insights-encryption.service.spec.ts | 4 +- .../risk-insights-encryption.service.ts | 12 +- .../risk-insights-report.service.spec.ts | 152 ++++++--- .../services/risk-insights-report.service.ts | 206 ++++++------ .../all-applications.component.ts | 13 +- .../risk-insights.component.ts | 3 + 16 files changed, 630 insertions(+), 374 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts index 8fbe45efeab..3f679924df9 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts @@ -6,7 +6,11 @@ import { LEGACY_CipherHealthReportDetail, LEGACY_CipherHealthReportUriDetail, } from "../models/password-health"; -import { ApplicationHealthReportDetail } from "../models/report-models"; +import { + ApplicationHealthReportDetail, + OrganizationReportSummary, + RiskInsightsReportData, +} from "../models/report-models"; import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; export function flattenMemberDetails( @@ -144,3 +148,34 @@ export function getApplicationReportDetail( return reportDetail; } + +/** + * Create a new Risk Insights Report + * + * @returns An empty report + */ +export function createNewReportData(): RiskInsightsReportData { + return { + data: [], + summary: createNewSummaryData(), + }; +} + +/** + * Create a new Risk Insights Report Summary + * + * @returns An empty report summary + */ +export function createNewSummaryData(): OrganizationReportSummary { + return { + totalMemberCount: 0, + totalAtRiskMemberCount: 0, + totalApplicationCount: 0, + totalAtRiskApplicationCount: 0, + totalCriticalMemberCount: 0, + totalCriticalAtRiskMemberCount: 0, + totalCriticalApplicationCount: 0, + totalCriticalAtRiskApplicationCount: 0, + newApplications: [], + }; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/index.ts index b2221a94a89..d6295daa03f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/index.ts @@ -1 +1,2 @@ export * from "./services"; +export * from "./models"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts index 1386b2d044f..89293651a23 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts @@ -1,7 +1,10 @@ -import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { PasswordHealthReportApplicationId, RiskInsightsReport } from "./report-models"; +import { createNewSummaryData } from "../helpers"; + +import { OrganizationReportSummary, PasswordHealthReportApplicationId } from "./report-models"; // -------------------- Password Health Report Models -------------------- /** @@ -32,18 +35,76 @@ export interface PasswordHealthReportApplicationsRequest { // -------------------- Risk Insights Report Models -------------------- export interface SaveRiskInsightsReportRequest { - data: RiskInsightsReport; + data: { + organizationId: OrganizationId; + date: string; + reportData: string; + contentEncryptionKey: string; + }; } -export interface SaveRiskInsightsReportResponse { +export class SaveRiskInsightsReportResponse extends BaseResponse { id: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("organizationId"); + } +} +export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsightsReportResponse { + return obj && typeof obj.id === "string" && obj.id !== ""; } -export interface GetRiskInsightsReportResponse { +export class GetRiskInsightsReportResponse extends BaseResponse { id: string; organizationId: OrganizationId; // TODO Update to use creationDate from server date: string; - reportData: EncryptedString; - contentEncryptionKey: EncryptedString; + reportData: EncString; + contentEncryptionKey: EncString; + + constructor(response: any) { + super(response); + + this.id = this.getResponseProperty("organizationId"); + this.organizationId = this.getResponseProperty("organizationId"); + this.date = this.getResponseProperty("date"); + this.reportData = new EncString(this.getResponseProperty("reportData")); + this.contentEncryptionKey = new EncString(this.getResponseProperty("contentEncryptionKey")); + } +} + +export class GetRiskInsightsSummaryResponse extends BaseResponse { + id: string; + organizationId: OrganizationId; + encryptedData: EncString; // Decrypted as OrganizationReportSummary + contentEncryptionKey: EncString; + + constructor(response: any) { + super(response); + // TODO Handle taking array of summary data and converting to array + this.id = this.getResponseProperty("id"); + this.organizationId = this.getResponseProperty("organizationId"); + this.encryptedData = this.getResponseProperty("encryptedData"); + this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); + } + + // TODO + getSummary(): OrganizationReportSummary { + return createNewSummaryData(); + } +} +export class GetRiskInsightsApplicationDataResponse extends BaseResponse { + reportId: string; + organizationId: OrganizationId; + encryptedData: EncString; + contentEncryptionKey: EncString; + + constructor(response: any) { + super(response); + this.reportId = this.getResponseProperty("reportId"); + this.organizationId = this.getResponseProperty("organizationId"); + this.encryptedData = this.getResponseProperty("encryptedData"); + this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts new file mode 100644 index 00000000000..b8fcfe251ff --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts @@ -0,0 +1,3 @@ +export * from "./api-models.types"; +export * from "./password-health"; +export * from "./report-models"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index 872e883e42e..e026a4475b7 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -1,9 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; -import { EncString } from "@bitwarden/sdk-internal"; import { ApplicationHealthReportDetail } from "./report-models"; @@ -40,7 +40,7 @@ export type ExposedPasswordDetail = { export interface EncryptedDataWithKey { organizationId: OrganizationId; encryptedData: EncString; - encryptionKey: EncString; + contentEncryptionKey: EncString; } export type LEGACY_MemberDetailsFlat = { @@ -76,10 +76,3 @@ export type LEGACY_CipherHealthReportUriDetail = { trimmedUri: string; cipher: CipherView; }; - -export interface EncryptedDataModel { - organizationId: OrganizationId; - encryptedData: string; - encryptionKey: string; - date: Date; -} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index acbec1592a0..1758bb41b1b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -1,6 +1,5 @@ import { Opaque } from "type-fest"; -import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; @@ -154,12 +153,6 @@ export interface RiskInsightsReportData { data: ApplicationHealthReportDetailEnriched[]; summary: OrganizationReportSummary; } -export interface RiskInsightsReport { - organizationId: OrganizationId; - date: string; - reportData: string; - reportKey: string; -} export type ReportScore = { label: string; badgeVariant: BadgeVariant; sortOrder: number }; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts index 865f5cd712f..3904c4c3865 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts @@ -32,6 +32,7 @@ export class PasswordHealthService { .passwordLeaked(cipher.login.password) .then((exposedCount) => ({ cipher, exposedCount })), ), + // [FIXME] ExposedDetails is can still return a null filter(({ exposedCount }) => exposedCount > 0), map(({ cipher, exposedCount }) => ({ exposedXTimes: exposedCount, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts index ffcdceadad7..4eda92f0eb3 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts @@ -1,11 +1,20 @@ import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; -import { SaveRiskInsightsReportRequest } from "../models/api-models.types"; -import { EncryptedDataModel } from "../models/password-health"; +import { + GetRiskInsightsApplicationDataResponse, + GetRiskInsightsReportResponse, + GetRiskInsightsSummaryResponse, + SaveRiskInsightsReportRequest, + SaveRiskInsightsReportResponse, +} from "../models/api-models.types"; +import { EncryptedDataWithKey } from "../models/password-health"; import { RiskInsightsApiService } from "./risk-insights-api.service"; @@ -13,14 +22,11 @@ describe("RiskInsightsApiService", () => { let service: RiskInsightsApiService; const mockApiService = mock(); + const mockId = "id"; const orgId = "org1" as OrganizationId; - - const getRiskInsightsReportResponse = { - organizationId: orgId, - date: new Date().toISOString(), - reportData: "test", - reportKey: "test-key", - }; + const mockReportId = "report-1"; + const mockKey = "encryption-key-1"; + const mockData = "encrypted-data"; const reportData = makeEncString("test").encryptedString?.toString() ?? ""; const reportKey = makeEncString("test-key").encryptedString?.toString() ?? ""; @@ -30,12 +36,9 @@ describe("RiskInsightsApiService", () => { organizationId: orgId, date: new Date().toISOString(), reportData: reportData, - reportKey: reportKey, + contentEncryptionKey: reportKey, }, }; - const saveRiskInsightsReportResponse = { - ...saveRiskInsightsReportRequest.data, - }; beforeEach(() => { service = new RiskInsightsApiService(mockApiService); @@ -45,11 +48,19 @@ describe("RiskInsightsApiService", () => { expect(service).toBeTruthy(); }); - it("Get Report: should call apiService.send with correct parameters and return the response for getRiskInsightsReport ", (done) => { + it("getRiskInsightsReport$ should call apiService.send with correct parameters and return the response", () => { + const getRiskInsightsReportResponse = { + id: mockId, + organizationId: orgId, + date: new Date().toISOString(), + reportData: mockData, + contentEncryptionKey: mockKey, + }; + mockApiService.send.mockReturnValue(Promise.resolve(getRiskInsightsReportResponse)); service.getRiskInsightsReport$(orgId).subscribe((result) => { - expect(result).toEqual(getRiskInsightsReportResponse); + expect(result).toEqual(new GetRiskInsightsReportResponse(getRiskInsightsReportResponse)); expect(mockApiService.send).toHaveBeenCalledWith( "GET", `/reports/organizations/${orgId.toString()}/latest`, @@ -57,187 +68,173 @@ describe("RiskInsightsApiService", () => { true, true, ); - done(); }); }); - it("Get Report: should return null if apiService.send rejects with 404 error for getRiskInsightsReport", (done) => { - const error = { statusCode: 404 }; - mockApiService.send.mockReturnValue(Promise.reject(error)); + it("getRiskInsightsReport$ should return null if apiService.send rejects with 404 error", async () => { + const mockError = new ErrorResponse(null, 404); + mockApiService.send.mockReturnValue(Promise.reject(mockError)); - service.getRiskInsightsReport$(orgId).subscribe((result) => { - expect(result).toBeNull(); - done(); - }); + const result = await firstValueFrom(service.getRiskInsightsReport$(orgId)); + + expect(result).toBeNull(); }); - it("Get Report: should throw error if apiService.send rejects with non-404 error for getRiskInsightsReport", (done) => { + it("getRiskInsightsReport$ should propagate errors if apiService.send rejects 500 server error", async () => { const error = { statusCode: 500, message: "Server error" }; mockApiService.send.mockReturnValue(Promise.reject(error)); - service.getRiskInsightsReport$(orgId).subscribe({ - next: () => { - // Should not reach here - fail("Expected error to be thrown"); - }, - error: () => { - expect(mockApiService.send).toHaveBeenCalledWith( - "GET", - `/reports/organizations/${orgId.toString()}/latest`, - null, - true, - true, - ); - done(); - }, - complete: () => { - done(); - }, - }); + await expect(firstValueFrom(service.getRiskInsightsReport$(orgId))).rejects.toEqual(error); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId.toString()}/latest`, + null, + true, + true, + ); }); - it("Save Report: should call apiService.send with correct parameters for saveRiskInsightsReport", (done) => { - mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportResponse)); + it("saveRiskInsightsReport$ should call apiService.send with correct parameters", async () => { + mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportRequest)); - service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId).subscribe((result) => { - expect(result).toEqual(saveRiskInsightsReportResponse); - expect(mockApiService.send).toHaveBeenCalledWith( - "POST", - `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, - true, - true, - ); - done(); - }); + const result = await firstValueFrom( + service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId), + ); + + expect(result).toEqual(new SaveRiskInsightsReportResponse(saveRiskInsightsReportRequest)); + expect(mockApiService.send).toHaveBeenCalledWith( + "POST", + `/reports/organizations/${orgId.toString()}`, + saveRiskInsightsReportRequest.data, + true, + true, + ); }); - it("Save Report: should propagate errors from apiService.send for saveRiskInsightsReport - 1", (done) => { + it("saveRiskInsightsReport$ should propagate errors from apiService.send for saveRiskInsightsReport - 1", async () => { const error = { statusCode: 500, message: "Internal Server Error" }; mockApiService.send.mockReturnValue(Promise.reject(error)); - service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId).subscribe({ - next: () => { - fail("Expected error to be thrown"); - }, - error: () => { - expect(mockApiService.send).toHaveBeenCalledWith( - "POST", - `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, - true, - true, - ); - done(); - }, - complete: () => { - done(); - }, - }); + await expect( + firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), + ).rejects.toEqual(error); + + expect(mockApiService.send).toHaveBeenCalledWith( + "POST", + `/reports/organizations/${orgId.toString()}`, + saveRiskInsightsReportRequest.data, + true, + true, + ); }); - it("Save Report: should propagate network errors from apiService.send for saveRiskInsightsReport - 2", (done) => { + it("saveRiskInsightsReport$ should propagate network errors from apiService.send - 2", async () => { const error = new Error("Network error"); mockApiService.send.mockReturnValue(Promise.reject(error)); - service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId).subscribe({ - next: () => { - fail("Expected error to be thrown"); - }, - error: () => { - expect(mockApiService.send).toHaveBeenCalledWith( - "POST", - `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, - true, - true, - ); - done(); - }, - complete: () => { - done(); - }, - }); + await expect( + firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), + ).rejects.toEqual(error); + + expect(mockApiService.send).toHaveBeenCalledWith( + "POST", + `/reports/organizations/${orgId.toString()}`, + saveRiskInsightsReportRequest.data, + true, + true, + ); }); - it("Get Summary: should call apiService.send with correct parameters and return an Observable", (done) => { + it("getRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => { const minDate = new Date("2024-01-01"); const maxDate = new Date("2024-01-31"); - const mockResponse: EncryptedDataModel[] = [{ encryptedData: "abc" } as EncryptedDataModel]; + const mockResponse = [ + { + reportId: mockReportId, + organizationId: orgId, + encryptedData: mockData, + contentEncryptionKey: mockKey, + }, + ]; mockApiService.send.mockResolvedValueOnce(mockResponse); - service.getRiskInsightsSummary$(orgId, minDate, maxDate).subscribe((result) => { - expect(mockApiService.send).toHaveBeenCalledWith( - "GET", - `/reports/organizations/${orgId.toString()}/data/summary?startDate=${minDate.toISOString().split("T")[0]}&endDate=${maxDate.toISOString().split("T")[0]}`, - null, - true, - true, - ); - expect(result).toEqual(mockResponse); - done(); - }); + const result = await firstValueFrom(service.getRiskInsightsSummary$(orgId, minDate, maxDate)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId.toString()}/data/summary?startDate=${minDate.toISOString().split("T")[0]}&endDate=${maxDate.toISOString().split("T")[0]}`, + null, + true, + true, + ); + expect(result).toEqual(new GetRiskInsightsSummaryResponse(mockResponse)); }); - it("Update Summary: should call apiService.send with correct parameters and return an Observable", (done) => { - const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel; + it("updateRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => { + const data: EncryptedDataWithKey = { + organizationId: orgId, + encryptedData: new EncString(mockData), + contentEncryptionKey: new EncString(mockKey), + }; + const reportId = "report123" as OrganizationReportId; mockApiService.send.mockResolvedValueOnce(undefined); - service.updateRiskInsightsSummary$(data, orgId, reportId).subscribe((result) => { - expect(mockApiService.send).toHaveBeenCalledWith( - "PATCH", - `/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`, - data, - true, - true, - ); - expect(result).toBeUndefined(); - done(); - }); + const result = await firstValueFrom(service.updateRiskInsightsSummary$(data, orgId, reportId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + `/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`, + data, + true, + true, + ); + expect(result).toBeUndefined(); }); - it("Get Applications: should call apiService.send with correct parameters and return an Observable", (done) => { + it("getRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => { const reportId = "report123" as OrganizationReportId; - const mockResponse: EncryptedDataModel | null = { - encryptedData: "abc", - } as EncryptedDataModel; + const mockResponse: EncryptedDataWithKey | null = { + organizationId: orgId, + encryptedData: new EncString(mockData), + contentEncryptionKey: new EncString(mockKey), + }; mockApiService.send.mockResolvedValueOnce(mockResponse); - service.getRiskInsightsApplicationData$(orgId, reportId).subscribe((result) => { - expect(mockApiService.send).toHaveBeenCalledWith( - "GET", - `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - null, - true, - true, - ); - expect(result).toEqual(mockResponse); - done(); - }); + const result = await firstValueFrom(service.getRiskInsightsApplicationData$(orgId, reportId)); + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, + null, + true, + true, + ); + expect(result).toEqual(new GetRiskInsightsApplicationDataResponse(mockResponse)); }); - it("Update Applications: should call apiService.send with correct parameters and return an Observable", (done) => { - const applicationData: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel; + it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => { + const applicationData: EncryptedDataWithKey = { + organizationId: orgId, + encryptedData: new EncString(mockData), + contentEncryptionKey: new EncString(mockKey), + }; const reportId = "report123" as OrganizationReportId; mockApiService.send.mockResolvedValueOnce(undefined); - - service - .updateRiskInsightsApplicationData$(applicationData, orgId, reportId) - .subscribe((result) => { - expect(mockApiService.send).toHaveBeenCalledWith( - "PATCH", - `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - applicationData, - true, - true, - ); - expect(result).toBeUndefined(); - done(); - }); + const result = await firstValueFrom( + service.updateRiskInsightsApplicationData$(applicationData, orgId, reportId), + ); + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, + applicationData, + true, + true, + ); + expect(result).toBeUndefined(); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts index 9d8c2291749..8f40ae91b47 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts @@ -1,29 +1,38 @@ -import { from, Observable } from "rxjs"; +import { catchError, from, map, Observable, of, throwError } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { + GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, + GetRiskInsightsSummaryResponse, SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, } from "../models/api-models.types"; -import { EncryptedDataModel } from "../models/password-health"; +import { EncryptedDataWithKey } from "../models/password-health"; export class RiskInsightsApiService { constructor(private apiService: ApiService) {} getRiskInsightsReport$(orgId: OrganizationId): Observable { - const dbResponse = this.apiService - .send("GET", `/reports/organizations/${orgId.toString()}/latest`, null, true, true) - .catch((error: any): any => { - if (error.statusCode === 404) { - return null; // Handle 404 by returning null or an appropriate default value + const dbResponse = this.apiService.send( + "GET", + `/reports/organizations/${orgId.toString()}/latest`, + null, + true, + true, + ); + return from(dbResponse).pipe( + map((response) => new GetRiskInsightsReportResponse(response)), + catchError((error: unknown) => { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return of(null); // Handle 404 by returning null or an appropriate default value } - throw error; // Re-throw other errors - }); - - return from(dbResponse as Promise); + return throwError(() => error); // Re-throw other errors + }), + ); } saveRiskInsightsReport$( @@ -38,14 +47,14 @@ export class RiskInsightsApiService { true, ); - return from(dbResponse as Promise); + return from(dbResponse).pipe(map((response) => new SaveRiskInsightsReportResponse(response))); } getRiskInsightsSummary$( orgId: string, minDate: Date, maxDate: Date, - ): Observable { + ): Observable { const minDateStr = minDate.toISOString().split("T")[0]; const maxDateStr = maxDate.toISOString().split("T")[0]; const dbResponse = this.apiService.send( @@ -56,11 +65,11 @@ export class RiskInsightsApiService { true, ); - return from(dbResponse as Promise); + return from(dbResponse).pipe(map((response) => new GetRiskInsightsSummaryResponse(response))); } updateRiskInsightsSummary$( - summaryData: EncryptedDataModel, + summaryData: EncryptedDataWithKey, organizationId: OrganizationId, reportId: OrganizationReportId, ): Observable { @@ -78,7 +87,7 @@ export class RiskInsightsApiService { getRiskInsightsApplicationData$( orgId: OrganizationId, reportId: OrganizationReportId, - ): Observable { + ): Observable { const dbResponse = this.apiService.send( "GET", `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, @@ -87,11 +96,13 @@ export class RiskInsightsApiService { true, ); - return from(dbResponse as Promise); + return from(dbResponse).pipe( + map((response) => new GetRiskInsightsApplicationDataResponse(response)), + ); } updateRiskInsightsApplicationData$( - applicationData: EncryptedDataModel, + applicationData: EncryptedDataWithKey, orgId: OrganizationId, reportId: OrganizationReportId, ): Observable { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts index cc90fb6940a..7038844998d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts @@ -1,5 +1,14 @@ -import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; -import { finalize, switchMap, withLatestFrom, map } from "rxjs/operators"; +import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of } from "rxjs"; +import { + distinctUntilChanged, + exhaustMap, + filter, + finalize, + map, + switchMap, + tap, + withLatestFrom, +} from "rxjs/operators"; import { getOrganizationById, @@ -17,6 +26,7 @@ import { DrawerDetails, ApplicationHealthReportDetail, ApplicationHealthReportDetailEnriched, + ReportDetailsAndSummary, } from "../models/report-models"; import { CriticalAppsService } from "./critical-apps.service"; @@ -66,6 +76,15 @@ export class RiskInsightsDataService { }); drawerDetails$ = this.drawerDetailsSubject.asObservable(); + // ------------------------- Report Variables ---------------- + // The last run report details + private reportResultsSubject = new BehaviorSubject(null); + reportResults$ = this.reportResultsSubject.asObservable(); + // Is a report being generated + private isRunningReportSubject = new BehaviorSubject(false); + isRunningReport$ = this.isRunningReportSubject.asObservable(); + // The error from report generation if there was an error + constructor( private accountService: AccountService, private criticalAppsService: CriticalAppsService, @@ -81,7 +100,7 @@ export class RiskInsightsDataService { this.userIdSubject.next(userId); } - // [FIXME] getOrganizationById is now deprecated - update when we can + // [FIXME] getOrganizationById is now deprecated - replace with appropriate method // Fetch organization details const org = await firstValueFrom( this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)), @@ -96,20 +115,18 @@ export class RiskInsightsDataService { // Load critical applications for organization await this.criticalAppsService.loadOrganizationContext(organizationId, userId); - // TODO: PM-25613 - // // Load existing report + // Load existing report + this.fetchLastReport(organizationId, userId); - // this.fetchLastReport(organizationId, userId); - - // // Setup new report generation - // this._runApplicationsReport().subscribe({ - // next: (result) => { - // this.isRunningReportSubject.next(false); - // }, - // error: () => { - // this.errorSubject.next("Failed to save report"); - // }, - // }); + // Setup new report generation + this._runApplicationsReport().subscribe({ + next: (result) => { + this.isRunningReportSubject.next(false); + }, + error: () => { + this.errorSubject.next("Failed to save report"); + }, + }); } /** @@ -276,4 +293,88 @@ export class RiskInsightsDataService { }); } }; + + // ------------------- Trigger Report Generation ------------------- + /** Trigger generating a report based on the current applications */ + triggerReport(): void { + this.isRunningReportSubject.next(true); + } + + /** + * Fetches the applications report and updates the applicationsSubject. + * @param organizationId The ID of the organization. + */ + fetchLastReport(organizationId: OrganizationId, userId: UserId): void { + this.isLoadingSubject.next(true); + + this.reportService + .getRiskInsightsReport$(organizationId, userId) + .pipe( + switchMap((report) => { + return this.enrichReportData$(report.data).pipe( + map((enrichedReport) => ({ + data: enrichedReport, + summary: report.summary, + })), + ); + }), + finalize(() => { + this.isLoadingSubject.next(false); + }), + ) + .subscribe({ + next: ({ data, summary }) => { + this.reportResultsSubject.next({ + data, + summary, + dateCreated: new Date(), + }); + this.errorSubject.next(null); + this.isLoadingSubject.next(false); + }, + error: () => { + this.errorSubject.next("Failed to fetch report"); + this.reportResultsSubject.next(null); + this.isLoadingSubject.next(false); + }, + }); + } + + private _runApplicationsReport() { + return this.isRunningReport$.pipe( + distinctUntilChanged(), + filter((isRunning) => isRunning), + withLatestFrom(this.organizationDetails$, this.userId$), + exhaustMap(([_, organizationDetails, userId]) => { + const organizationId = organizationDetails?.organizationId; + if (!organizationId || !userId) { + return EMPTY; + } + + // Generate the report + return this.reportService.generateApplicationsReport$(organizationId).pipe( + map((data) => ({ + data, + summary: this.reportService.generateApplicationsSummary(data), + })), + switchMap(({ data, summary }) => + this.enrichReportData$(data).pipe( + map((enrichedData) => ({ data: enrichedData, summary })), + ), + ), + tap(({ data, summary }) => { + this.reportResultsSubject.next({ data, summary, dateCreated: new Date() }); + this.errorSubject.next(null); + }), + switchMap(({ data, summary }) => { + // Just returns ID + return this.reportService.saveRiskInsightsReport$(data, summary, { + organizationId, + userId, + }); + }), + ); + }), + ); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts index dae56327c29..9b7bb3b7258 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts @@ -70,8 +70,8 @@ describe("RiskInsightsEncryptionService", () => { ); expect(result).toEqual({ organizationId: orgId, - encryptedData: ENCRYPTED_TEXT, - encryptionKey: ENCRYPTED_KEY, + encryptedData: new EncString(ENCRYPTED_TEXT), + contentEncryptionKey: new EncString(ENCRYPTED_KEY), }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts index f3c3a68b470..7bf01b04a63 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts @@ -51,13 +51,13 @@ export class RiskInsightsEncryptionService { throw new Error("Encryption failed, encrypted strings are null"); } - const encryptedData = dataEncrypted.encryptedString; - const encryptionKey = wrappedEncryptionKey.encryptedString; + const encryptedData = dataEncrypted; + const contentEncryptionKeyString = wrappedEncryptionKey; - const encryptedDataPacket = { - organizationId: organizationId, - encryptedData: encryptedData, - encryptionKey: encryptionKey, + const encryptedDataPacket: EncryptedDataWithKey = { + organizationId, + encryptedData, + contentEncryptionKey: contentEncryptionKeyString, }; return encryptedDataPacket; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts index 6c6fbb5b92c..18836fb1319 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts @@ -1,13 +1,24 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; -import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { GetRiskInsightsReportResponse } from "../models/api-models.types"; +import { createNewSummaryData } from "../helpers"; +import { + GetRiskInsightsReportResponse, + SaveRiskInsightsReportResponse, +} from "../models/api-models.types"; +import { EncryptedDataWithKey } from "../models/password-health"; +import { + ApplicationHealthReportDetail, + OrganizationReportSummary, + RiskInsightsReportData, +} from "../models/report-models"; import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; import { mockCiphers } from "./ciphers.mock"; @@ -31,10 +42,20 @@ describe("RiskInsightsReportService", () => { decryptRiskInsightsReport: jest.fn().mockResolvedValue("decryptedReportData"), }); - // Mock data - const mockOrgId = "orgId" as OrganizationId; + // Non changing mock data + const mockOrganizationId = "orgId" as OrganizationId; + const mockUserId = "userId" as UserId; + const ENCRYPTED_TEXT = "This data has been encrypted"; + const ENCRYPTED_KEY = "Re-encrypted Cipher Key"; + const mockEncryptedText = new EncString(ENCRYPTED_TEXT); + const mockEncryptedKey = new EncString(ENCRYPTED_KEY); + + // Changing mock data let mockCipherViews: CipherView[]; let mockMemberDetails: MemberCipherDetailsResponse[]; + let mockReport: ApplicationHealthReportDetail[]; + let mockSummary: OrganizationReportSummary; + let mockEncryptedReport: EncryptedDataWithKey; beforeEach(() => { cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers); @@ -115,6 +136,27 @@ describe("RiskInsightsReportService", () => { email: "user3@other.com", }), ]; + + mockReport = [ + { + applicationName: "app1", + passwordCount: 0, + atRiskPasswordCount: 0, + atRiskCipherIds: [], + memberCount: 0, + atRiskMemberCount: 0, + memberDetails: [], + atRiskMemberDetails: [], + cipherIds: [], + }, + ]; + mockSummary = createNewSummaryData(); + + mockEncryptedReport = { + organizationId: mockOrganizationId, + encryptedData: mockEncryptedText, + contentEncryptionKey: mockEncryptedKey, + }; }); it("should group and aggregate application health reports correctly", (done) => { @@ -137,7 +179,7 @@ describe("RiskInsightsReportService", () => { }); it("should generate the raw data report correctly", async () => { - const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrgId)); + const result = await firstValueFrom(service.LEGACY_generateRawDataReport$(mockOrganizationId)); expect(result).toHaveLength(6); @@ -163,7 +205,7 @@ describe("RiskInsightsReportService", () => { }); it("should generate the raw data + uri report correctly", async () => { - const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrgId)); + const result = await firstValueFrom(service.generateRawDataUriReport$(mockOrganizationId)); expect(result).toHaveLength(11); @@ -186,7 +228,9 @@ describe("RiskInsightsReportService", () => { }); it("should generate applications health report data correctly", async () => { - const result = await firstValueFrom(service.LEGACY_generateApplicationsReport$(mockOrgId)); + const result = await firstValueFrom( + service.LEGACY_generateApplicationsReport$(mockOrganizationId), + ); expect(result).toHaveLength(8); @@ -228,7 +272,7 @@ describe("RiskInsightsReportService", () => { it("should generate applications summary data correctly", async () => { const reportResult = await firstValueFrom( - service.LEGACY_generateApplicationsReport$(mockOrgId), + service.LEGACY_generateApplicationsReport$(mockOrganizationId), ); const reportSummary = service.generateApplicationsSummary(reportResult); @@ -238,56 +282,81 @@ describe("RiskInsightsReportService", () => { expect(reportSummary.totalAtRiskApplicationCount).toEqual(7); }); - describe("saveRiskInsightsReport", () => { - it("should not update subjects if save response does not have id", async () => { - const organizationId = "orgId" as OrganizationId; - const userId = "userId" as UserId; - const report = [{ applicationName: "app1" }] as any; - - const encryptedReport = { - organizationId: organizationId as OrganizationId, - encryptedData: "encryptedData" as EncryptedString, - encryptionKey: "encryptionKey" as EncryptedString, - }; - + describe("saveRiskInsightsReport$", () => { + it("should not update subjects if save response does not have id", (done) => { mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( - encryptedReport, + mockEncryptedReport, ); - const saveResponse = { id: "" }; // Simulating no ID in response + const saveResponse = new SaveRiskInsightsReportResponse({ id: "" }); // Simulating no ID in response mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse)); - const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); - const summarySubjectSpy = jest.spyOn((service as any).riskInsightsSummarySubject, "next"); - - await service.saveRiskInsightsReport(organizationId, userId, report); - - expect(reportSubjectSpy).not.toHaveBeenCalled(); - expect(summarySubjectSpy).not.toHaveBeenCalled(); + service + .saveRiskInsightsReport$(mockReport, mockSummary, { + organizationId: mockOrganizationId, + userId: mockUserId, + }) + .subscribe({ + next: (response) => { + done.fail("Expected error due to invalid response"); + }, + error: (error: unknown) => { + if (error instanceof ErrorResponse && error.statusCode) { + expect(error.message).toBe("Invalid response from API"); + } + done(); + }, + }); }); + + it("should encrypt and save report, then update subjects", async () => {}); }); - describe("getRiskInsightsReport", () => { + describe("getRiskInsightsReport$", () => { beforeEach(() => { // Reset the mocks before each test jest.clearAllMocks(); }); - it("should call riskInsightsApiService.getRiskInsightsReport with the correct organizationId", () => { + it("should call with the correct organizationId", async () => { // we need to ensure that the api is invoked with the specified organizationId // here it doesn't matter what the Api returns const apiResponse = { id: "reportId", date: new Date().toISOString(), - organizationId: "orgId", - reportData: "encryptedReportData", - contentEncryptionKey: "encryptionKey", + organizationId: mockOrganizationId, + reportData: mockEncryptedReport.encryptedData, + contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, } as GetRiskInsightsReportResponse; + const decryptedResponse: RiskInsightsReportData = { + data: [], + summary: { + totalMemberCount: 1, + totalAtRiskMemberCount: 1, + totalApplicationCount: 1, + totalAtRiskApplicationCount: 1, + totalCriticalMemberCount: 1, + totalCriticalAtRiskMemberCount: 1, + totalCriticalApplicationCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: [], + }, + }; + const organizationId = "orgId" as OrganizationId; const userId = "userId" as UserId; + + // Mock api returned encrypted data mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(apiResponse)); - service.getRiskInsightsReport(organizationId, userId); + + // Mock decrypted data + mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockReturnValue( + Promise.resolve(decryptedResponse), + ); + + await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); + expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith( organizationId, ); @@ -310,8 +379,8 @@ describe("RiskInsightsReportService", () => { id: "reportId", date: new Date().toISOString(), organizationId: organizationId as OrganizationId, - reportData: "encryptedReportData", - contentEncryptionKey: "encryptionKey", + reportData: mockEncryptedReport.encryptedData, + contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, } as GetRiskInsightsReportResponse; const decryptedReport = { @@ -322,12 +391,7 @@ describe("RiskInsightsReportService", () => { decryptedReport, ); - const reportSubjectSpy = jest.spyOn((service as any).riskInsightsReportSubject, "next"); - - service.getRiskInsightsReport(organizationId, userId); - - // Wait for all microtasks to complete - await Promise.resolve(); + const result = await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( organizationId, @@ -336,7 +400,7 @@ describe("RiskInsightsReportService", () => { expect.anything(), expect.any(Function), ); - expect(reportSubjectSpy).toHaveBeenCalledWith(decryptedReport.data); + expect(result).toEqual(decryptedReport); }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index 1839e89a1ae..d82366c0154 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe -// @ts-strict-ignore import { BehaviorSubject, concatMap, @@ -11,15 +9,17 @@ import { Observable, of, switchMap, + throwError, zip, } from "rxjs"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { + createNewReportData, + createNewSummaryData, flattenMemberDetails, getApplicationReportDetail, getFlattenedCipherDetails, @@ -27,6 +27,10 @@ import { getTrimmedCipherUris, getUniqueMembers, } from "../helpers/risk-insights-data-mappers"; +import { + isSaveRiskInsightsReportResponse, + SaveRiskInsightsReportResponse, +} from "../models/api-models.types"; import { LEGACY_CipherHealthReportDetail, LEGACY_CipherHealthReportUriDetail, @@ -53,17 +57,9 @@ export class RiskInsightsReportService { private riskInsightsReportSubject = new BehaviorSubject([]); riskInsightsReport$ = this.riskInsightsReportSubject.asObservable(); - private riskInsightsSummarySubject = new BehaviorSubject({ - totalMemberCount: 0, - totalAtRiskMemberCount: 0, - totalApplicationCount: 0, - totalAtRiskApplicationCount: 0, - totalCriticalMemberCount: 0, - totalCriticalAtRiskMemberCount: 0, - totalCriticalApplicationCount: 0, - totalCriticalAtRiskApplicationCount: 0, - newApplications: [], - }); + private riskInsightsSummarySubject = new BehaviorSubject( + createNewSummaryData(), + ); riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable(); // [FIXME] CipherData @@ -189,11 +185,8 @@ export class RiskInsightsReportService { cipherHealthReportDetails.forEach((app) => { app.atRiskMemberDetails.forEach((member) => { - if (memberRiskMap.has(member.email)) { - memberRiskMap.set(member.email, memberRiskMap.get(member.email) + 1); - } else { - memberRiskMap.set(member.email, 1); - } + const currentCount = memberRiskMap.get(member.email) ?? 0; + memberRiskMap.set(member.email, currentCount + 1); }); }); @@ -206,25 +199,24 @@ export class RiskInsightsReportService { generateAtRiskApplicationList( cipherHealthReportDetails: ApplicationHealthReportDetail[], ): AtRiskApplicationDetail[] { - const appsRiskMap = new Map(); + const applicationPasswordRiskMap = new Map(); cipherHealthReportDetails .filter((app) => app.atRiskPasswordCount > 0) .forEach((app) => { - if (appsRiskMap.has(app.applicationName)) { - appsRiskMap.set( - app.applicationName, - appsRiskMap.get(app.applicationName) + app.atRiskPasswordCount, - ); - } else { - appsRiskMap.set(app.applicationName, app.atRiskPasswordCount); - } + const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0; + applicationPasswordRiskMap.set( + app.applicationName, + atRiskPasswordCount + app.atRiskPasswordCount, + ); }); - return Array.from(appsRiskMap.entries()).map(([applicationName, atRiskPasswordCount]) => ({ - applicationName, - atRiskPasswordCount, - })); + return Array.from(applicationPasswordRiskMap.entries()).map( + ([applicationName, atRiskPasswordCount]) => ({ + applicationName, + atRiskPasswordCount, + }), + ); } /** @@ -270,78 +262,88 @@ export class RiskInsightsReportService { return dataWithCiphers; } - getRiskInsightsReport(organizationId: OrganizationId, userId: UserId): void { - this.riskInsightsApiService - .getRiskInsightsReport$(organizationId) - .pipe( - switchMap((response) => { - if (!response) { - // Return an empty report and summary if response is falsy - return of({ - data: [], - summary: { - totalMemberCount: 0, - totalAtRiskMemberCount: 0, - totalApplicationCount: 0, - totalAtRiskApplicationCount: 0, - totalCriticalMemberCount: 0, - totalCriticalAtRiskMemberCount: 0, - totalCriticalApplicationCount: 0, - totalCriticalAtRiskApplicationCount: 0, - newApplications: [], - }, - }); - } - return from( - this.riskInsightsEncryptionService.decryptRiskInsightsReport( - organizationId, - userId, - new EncString(response.reportData), - new EncString(response.contentEncryptionKey), - (data) => data as RiskInsightsReportData, - ), - ); - }), - ) - .subscribe({ - next: (decryptRiskInsightsReport) => { - this.riskInsightsReportSubject.next(decryptRiskInsightsReport.data); - this.riskInsightsSummarySubject.next(decryptRiskInsightsReport.summary); - }, - }); - } - - async saveRiskInsightsReport( + /** + * Gets the risk insights report for a specific organization and user. + * + * @param organizationId + * @param userId + * @returns An observable that emits the decrypted risk insights report data. + */ + getRiskInsightsReport$( organizationId: OrganizationId, userId: UserId, + ): Observable { + return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( + switchMap((response): Observable => { + if (!response) { + // Return an empty report and summary if response is falsy + return of(createNewReportData()); + } + if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") { + return throwError(() => new Error("Report key not found")); + } + if (!response.reportData) { + return throwError(() => new Error("Report data not found")); + } + return from( + this.riskInsightsEncryptionService.decryptRiskInsightsReport( + organizationId, + userId, + response.reportData, + response.contentEncryptionKey, + (data) => data as RiskInsightsReportData, + ), + ).pipe(map((decryptedReport) => decryptedReport ?? createNewReportData())); + }), + ); + } + + /** + * Encrypts the risk insights report data for a specific organization. + * @param organizationId The ID of the organization. + * @param userId The ID of the user. + * @param report The report data to encrypt. + * @returns A promise that resolves to an object containing the encrypted data and encryption key. + */ + saveRiskInsightsReport$( report: ApplicationHealthReportDetail[], - ): Promise { - const riskReport = { - data: report, - }; - - const encryptedReport = await this.riskInsightsEncryptionService.encryptRiskInsightsReport( - organizationId, - userId, - riskReport, + summary: OrganizationReportSummary, + encryptionParameters: { + organizationId: OrganizationId; + userId: UserId; + }, + ): Observable { + return from( + this.riskInsightsEncryptionService.encryptRiskInsightsReport( + encryptionParameters.organizationId, + encryptionParameters.userId, + { + data: report, + summary: summary, + }, + ), + ).pipe( + map(({ encryptedData, contentEncryptionKey }) => ({ + data: { + organizationId: encryptionParameters.organizationId, + date: new Date().toISOString(), + reportData: encryptedData.toSdk(), + contentEncryptionKey: contentEncryptionKey.toSdk(), + }, + })), + switchMap((encryptedReport) => + this.riskInsightsApiService.saveRiskInsightsReport$( + encryptedReport, + encryptionParameters.organizationId, + ), + ), + map((response) => { + if (!isSaveRiskInsightsReportResponse(response)) { + throw new Error("Invalid response from API"); + } + return response; + }), ); - - const saveRequest = { - data: { - organizationId: organizationId, - date: new Date().toISOString(), - reportData: encryptedReport.encryptedData, - reportKey: encryptedReport.encryptionKey, - }, - }; - - const response = await firstValueFrom( - this.riskInsightsApiService.saveRiskInsightsReport$(saveRequest, organizationId), - ); - - if (response && response.id) { - this.riskInsightsReportSubject.next(report); - } } /** @@ -374,7 +376,7 @@ export class RiskInsightsReportService { passwordUseMap.set(cipher.login.password, 1); } - const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id); + const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id); // Get the cipher members const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); @@ -599,7 +601,7 @@ export class RiskInsightsReportService { return this.passwordHealthService.auditPasswordLeaks$(validCiphers).pipe( map((exposedDetails) => { return validCiphers.map((cipher) => { - const exposedPassword = exposedDetails.find((x) => x.cipherId === cipher.id); + const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id); const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); const result = { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts index 51efafe501d..390d1f8f9d5 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts @@ -10,6 +10,7 @@ import { RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { LEGACY_ApplicationHealthReportDetailWithCriticalFlag, LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, @@ -66,17 +67,7 @@ export class AllApplicationsComponent implements OnInit { protected organization = new Organization(); noItemsIcon = Security; protected markingAsCritical = false; - protected applicationSummary: OrganizationReportSummary = { - totalMemberCount: 0, - totalAtRiskMemberCount: 0, - totalApplicationCount: 0, - totalAtRiskApplicationCount: 0, - totalCriticalMemberCount: 0, - totalCriticalAtRiskMemberCount: 0, - totalCriticalApplicationCount: 0, - totalCriticalAtRiskApplicationCount: 0, - newApplications: [], - }; + protected applicationSummary: OrganizationReportSummary = createNewSummaryData(); destroyRef = inject(DestroyRef); isLoading$: Observable = of(false); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index a34cae44f14..208ba59fb9d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -146,6 +146,9 @@ export class RiskInsightsComponent implements OnInit { this._isDrawerOpen = details.open; }); } + runReport = () => { + this.dataService.triggerReport(); + }; /** * Refreshes the data by re-fetching the applications report.