mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
[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
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./services";
|
||||
export * from "./models";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./api-models.types";
|
||||
export * from "./password-health";
|
||||
export * from "./report-models";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ApiService>();
|
||||
|
||||
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,30 +68,24 @@ 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));
|
||||
|
||||
const result = await firstValueFrom(service.getRiskInsightsReport$(orgId));
|
||||
|
||||
service.getRiskInsightsReport$(orgId).subscribe((result) => {
|
||||
expect(result).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
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: () => {
|
||||
await expect(firstValueFrom(service.getRiskInsightsReport$(orgId))).rejects.toEqual(error);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId.toString()}/latest`,
|
||||
@@ -88,19 +93,16 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
done();
|
||||
},
|
||||
complete: () => {
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const result = await firstValueFrom(
|
||||
service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId),
|
||||
);
|
||||
|
||||
expect(result).toEqual(new SaveRiskInsightsReportResponse(saveRiskInsightsReportRequest));
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId.toString()}`,
|
||||
@@ -108,19 +110,16 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
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: () => {
|
||||
await expect(
|
||||
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)),
|
||||
).rejects.toEqual(error);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId.toString()}`,
|
||||
@@ -128,23 +127,16 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
done();
|
||||
},
|
||||
complete: () => {
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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: () => {
|
||||
await expect(
|
||||
firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)),
|
||||
).rejects.toEqual(error);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId.toString()}`,
|
||||
@@ -152,22 +144,24 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
done();
|
||||
},
|
||||
complete: () => {
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
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]}`,
|
||||
@@ -175,18 +169,22 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
done();
|
||||
});
|
||||
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) => {
|
||||
const result = await firstValueFrom(service.updateRiskInsightsSummary$(data, orgId, reportId));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`,
|
||||
@@ -195,19 +193,19 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
const result = await firstValueFrom(service.getRiskInsightsApplicationData$(orgId, reportId));
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
||||
@@ -215,20 +213,21 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
done();
|
||||
});
|
||||
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) => {
|
||||
const result = await firstValueFrom(
|
||||
service.updateRiskInsightsApplicationData$(applicationData, orgId, reportId),
|
||||
);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
||||
@@ -237,7 +236,5 @@ describe("RiskInsightsApiService", () => {
|
||||
true,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GetRiskInsightsReportResponse | null> {
|
||||
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<GetRiskInsightsReportResponse>);
|
||||
return throwError(() => error); // Re-throw other errors
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
saveRiskInsightsReport$(
|
||||
@@ -38,14 +47,14 @@ export class RiskInsightsApiService {
|
||||
true,
|
||||
);
|
||||
|
||||
return from(dbResponse as Promise<SaveRiskInsightsReportResponse>);
|
||||
return from(dbResponse).pipe(map((response) => new SaveRiskInsightsReportResponse(response)));
|
||||
}
|
||||
|
||||
getRiskInsightsSummary$(
|
||||
orgId: string,
|
||||
minDate: Date,
|
||||
maxDate: Date,
|
||||
): Observable<EncryptedDataModel[]> {
|
||||
): Observable<GetRiskInsightsSummaryResponse> {
|
||||
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<EncryptedDataModel[]>);
|
||||
return from(dbResponse).pipe(map((response) => new GetRiskInsightsSummaryResponse(response)));
|
||||
}
|
||||
|
||||
updateRiskInsightsSummary$(
|
||||
summaryData: EncryptedDataModel,
|
||||
summaryData: EncryptedDataWithKey,
|
||||
organizationId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<void> {
|
||||
@@ -78,7 +87,7 @@ export class RiskInsightsApiService {
|
||||
getRiskInsightsApplicationData$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<EncryptedDataModel | null> {
|
||||
): Observable<GetRiskInsightsApplicationDataResponse> {
|
||||
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<EncryptedDataModel | null>);
|
||||
return from(dbResponse).pipe(
|
||||
map((response) => new GetRiskInsightsApplicationDataResponse(response)),
|
||||
);
|
||||
}
|
||||
|
||||
updateRiskInsightsApplicationData$(
|
||||
applicationData: EncryptedDataModel,
|
||||
applicationData: EncryptedDataWithKey,
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<void> {
|
||||
|
||||
@@ -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<ReportDetailsAndSummary | null>(null);
|
||||
reportResults$ = this.reportResultsSubject.asObservable();
|
||||
// Is a report being generated
|
||||
private isRunningReportSubject = new BehaviorSubject<boolean>(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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRiskInsightsReport", () => {
|
||||
it("should encrypt and save report, then update subjects", async () => {});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ApplicationHealthReportDetail[]>([]);
|
||||
riskInsightsReport$ = this.riskInsightsReportSubject.asObservable();
|
||||
|
||||
private riskInsightsSummarySubject = new BehaviorSubject<OrganizationReportSummary>({
|
||||
totalMemberCount: 0,
|
||||
totalAtRiskMemberCount: 0,
|
||||
totalApplicationCount: 0,
|
||||
totalAtRiskApplicationCount: 0,
|
||||
totalCriticalMemberCount: 0,
|
||||
totalCriticalAtRiskMemberCount: 0,
|
||||
totalCriticalApplicationCount: 0,
|
||||
totalCriticalAtRiskApplicationCount: 0,
|
||||
newApplications: [],
|
||||
});
|
||||
private riskInsightsSummarySubject = new BehaviorSubject<OrganizationReportSummary>(
|
||||
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<string, number>();
|
||||
const applicationPasswordRiskMap = new Map<string, number>();
|
||||
|
||||
cipherHealthReportDetails
|
||||
.filter((app) => app.atRiskPasswordCount > 0)
|
||||
.forEach((app) => {
|
||||
if (appsRiskMap.has(app.applicationName)) {
|
||||
appsRiskMap.set(
|
||||
const atRiskPasswordCount = applicationPasswordRiskMap.get(app.applicationName) ?? 0;
|
||||
applicationPasswordRiskMap.set(
|
||||
app.applicationName,
|
||||
appsRiskMap.get(app.applicationName) + app.atRiskPasswordCount,
|
||||
atRiskPasswordCount + app.atRiskPasswordCount,
|
||||
);
|
||||
} else {
|
||||
appsRiskMap.set(app.applicationName, app.atRiskPasswordCount);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(appsRiskMap.entries()).map(([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) => {
|
||||
/**
|
||||
* 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<RiskInsightsReportData> {
|
||||
return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe(
|
||||
switchMap((response): Observable<RiskInsightsReportData> => {
|
||||
if (!response) {
|
||||
// Return an empty report and summary if response is falsy
|
||||
return of<RiskInsightsReportData>({
|
||||
data: [],
|
||||
summary: {
|
||||
totalMemberCount: 0,
|
||||
totalAtRiskMemberCount: 0,
|
||||
totalApplicationCount: 0,
|
||||
totalAtRiskApplicationCount: 0,
|
||||
totalCriticalMemberCount: 0,
|
||||
totalCriticalAtRiskMemberCount: 0,
|
||||
totalCriticalApplicationCount: 0,
|
||||
totalCriticalAtRiskApplicationCount: 0,
|
||||
newApplications: [],
|
||||
},
|
||||
});
|
||||
return of<RiskInsightsReportData>(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<RiskInsightsReportData>(
|
||||
organizationId,
|
||||
userId,
|
||||
new EncString(response.reportData),
|
||||
new EncString(response.contentEncryptionKey),
|
||||
response.reportData,
|
||||
response.contentEncryptionKey,
|
||||
(data) => data as RiskInsightsReportData,
|
||||
),
|
||||
);
|
||||
).pipe(map((decryptedReport) => decryptedReport ?? createNewReportData()));
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (decryptRiskInsightsReport) => {
|
||||
this.riskInsightsReportSubject.next(decryptRiskInsightsReport.data);
|
||||
this.riskInsightsSummarySubject.next(decryptRiskInsightsReport.summary);
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
async saveRiskInsightsReport(
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
/**
|
||||
* 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<void> {
|
||||
const riskReport = {
|
||||
data: report,
|
||||
};
|
||||
|
||||
const encryptedReport = await this.riskInsightsEncryptionService.encryptRiskInsightsReport(
|
||||
organizationId,
|
||||
userId,
|
||||
riskReport,
|
||||
);
|
||||
|
||||
const saveRequest = {
|
||||
data: {
|
||||
organizationId: organizationId,
|
||||
date: new Date().toISOString(),
|
||||
reportData: encryptedReport.encryptedData,
|
||||
reportKey: encryptedReport.encryptionKey,
|
||||
summary: OrganizationReportSummary,
|
||||
encryptionParameters: {
|
||||
organizationId: OrganizationId;
|
||||
userId: UserId;
|
||||
},
|
||||
};
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.riskInsightsApiService.saveRiskInsightsReport$(saveRequest, organizationId),
|
||||
);
|
||||
|
||||
if (response && response.id) {
|
||||
this.riskInsightsReportSubject.next(report);
|
||||
): Observable<SaveRiskInsightsReportResponse> {
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<boolean> = of(false);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user