mirror of
https://github.com/bitwarden/clients.git
synced 2026-07-01 21:10:49 +08:00
[PM-31942] Handle load/save Access Intelligence reports as files (pt. 1) (#19922)
- Includes model updates for making requests to the updated Access Intelligence report file APIs (changes here) - Adds AccessIntelligenceApiService for calls to new APIs. Includes default implementation + tests
This commit is contained in:
parent
086c625762
commit
ee291b41b7
@ -0,0 +1,29 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { FileUploadType } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { AccessReportApi } from "./access-report.api";
|
||||
|
||||
/**
|
||||
* Response model returned when creating an Access Intelligence report to be stored as a file.
|
||||
* Contains a presigned URL that is used to upload the file for the report.
|
||||
*
|
||||
* - See {@link AccessReportApi} for the nested report response model
|
||||
*/
|
||||
export class AccessReportFileApi extends BaseResponse {
|
||||
reportFileUploadUrl: string = "";
|
||||
reportResponse: AccessReportApi = new AccessReportApi();
|
||||
fileUploadType: FileUploadType = FileUploadType.Direct;
|
||||
|
||||
constructor(data: any = null) {
|
||||
super(data);
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reportFileUploadUrl = this.getResponseProperty("reportFileUploadUrl") ?? "";
|
||||
const reportResponse = this.getResponseProperty("reportResponse");
|
||||
this.reportResponse =
|
||||
reportResponse != null ? new AccessReportApi(reportResponse) : new AccessReportApi();
|
||||
this.fileUploadType = this.getResponseProperty("fileUploadType") ?? FileUploadType.Direct;
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@ import { AccessReport } from "../domain/access-report";
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { AccessReportView } from "../view/access-report.view";
|
||||
|
||||
import { ReportFileApi } from "./report-file.api";
|
||||
|
||||
/**
|
||||
* Converts an AccessReport API response
|
||||
*
|
||||
@ -24,6 +26,8 @@ export class AccessReportApi extends BaseResponse {
|
||||
memberRegistry: string = "";
|
||||
creationDate: string = "";
|
||||
contentEncryptionKey: string = "";
|
||||
reportFile?: ReportFileApi;
|
||||
reportFileDownloadUrl?: string;
|
||||
|
||||
constructor(data: any = null) {
|
||||
super(data);
|
||||
@ -39,6 +43,10 @@ export class AccessReportApi extends BaseResponse {
|
||||
this.summary = this.getResponseProperty("summaryData");
|
||||
this.memberRegistry = this.getResponseProperty("memberRegistry") ?? "";
|
||||
this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey");
|
||||
this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined;
|
||||
|
||||
const reportFile = this.getResponseProperty("reportFile");
|
||||
this.reportFile = reportFile != null ? new ReportFileApi(reportFile) : undefined;
|
||||
|
||||
// Use when individual values are encrypted
|
||||
// const summary = this.getResponseProperty("summaryData");
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
/**
|
||||
* Metadata for an uploaded report file.
|
||||
*/
|
||||
export class ReportFileApi extends BaseResponse {
|
||||
id: string;
|
||||
fileName: string = "";
|
||||
/** File size in bytes. Serialized as a string by the server. */
|
||||
size: number = 0;
|
||||
validated: boolean = false;
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
this.id = this.getResponseProperty("id");
|
||||
this.fileName = this.getResponseProperty("fileName") ?? "";
|
||||
this.size = Number(this.getResponseProperty("size") ?? 0);
|
||||
this.validated = this.getResponseProperty("validated") ?? false;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ export * from "./api/application-health.api";
|
||||
export * from "./api/access-report-settings.api";
|
||||
export * from "./api/access-report-summary.api";
|
||||
export * from "./api/access-report-metrics.api";
|
||||
export * from "./api/access-report-file.api";
|
||||
|
||||
// Data layer
|
||||
export * from "./data/access-report.data";
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
AccessReportApi,
|
||||
AccessReportFileApi,
|
||||
AccessReportMetricsApi,
|
||||
AccessReportSummaryApi,
|
||||
} from "../../models";
|
||||
|
||||
export interface AccessReportCreateRequest {
|
||||
contentEncryptionKey: string;
|
||||
summaryData: string;
|
||||
applicationData: string;
|
||||
metrics: AccessReportMetricsApi;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface AccessReportLegacyCreateRequest {
|
||||
reportData: string;
|
||||
contentEncryptionKey: string;
|
||||
summaryData: string;
|
||||
applicationData: string;
|
||||
metrics: AccessReportMetricsApi;
|
||||
}
|
||||
|
||||
export interface AccessReportSettingsUpdateRequest {
|
||||
summaryData: string;
|
||||
applicationData: string;
|
||||
metrics: AccessReportMetricsApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service handling server communication/API calls for Access Intelligence endpoints.
|
||||
*
|
||||
* Handles making HTTP requests to the Bitwarden server and transforms all responses into Api models. Source of truth for retrieving and updating Access Intelligence report data using the Bitwarden API.
|
||||
*/
|
||||
export abstract class AccessIntelligenceApiService {
|
||||
/**
|
||||
* Retrieves the latest Access Intelligence report for an Organization.
|
||||
* @param orgId - the ID of the Organization to retrieve the report for
|
||||
* @returns the latest Access Intelligence report
|
||||
*/
|
||||
abstract getLatestReport$(orgId: OrganizationId): Observable<AccessReportApi>;
|
||||
|
||||
/**
|
||||
* Creates an Access Intelligence report on the server, where the report contents are stored as a file.
|
||||
* @param orgId - the ID of the Organization to create the report for
|
||||
* @param request - contains data used to create the report
|
||||
* @returns observable emitting the server's response, which includes the created Access Intelligence report
|
||||
*/
|
||||
abstract createReport$(
|
||||
orgId: OrganizationId,
|
||||
request: AccessReportCreateRequest,
|
||||
): Observable<AccessReportFileApi>;
|
||||
|
||||
/**
|
||||
* Creates an Access Intelligence report on the server, where report contents are included directly in the request body.
|
||||
* @param orgId - the ID of the Organization to create the report for
|
||||
* @param request - contains the report data and metadata used to create the report
|
||||
* @returns observable emitting the created Access Intelligence report
|
||||
*/
|
||||
abstract createLegacyReport$(
|
||||
orgId: OrganizationId,
|
||||
request: AccessReportLegacyCreateRequest,
|
||||
): Observable<AccessReportApi>;
|
||||
|
||||
/**
|
||||
* Self-hosted only. Uploads a file containing the Access Intelligence report data directly to a Bitwarden self-hosted server.
|
||||
* @param orgId - the ID of the Organization the report belongs to
|
||||
* @param reportId - the ID of the report to upload the file for
|
||||
* @param file - the file containing the Access Intelligence report data
|
||||
* @param reportFileId - the ID of the report file returned from the server upon report creation
|
||||
* @returns observable that completes when the upload is successful
|
||||
*/
|
||||
abstract uploadReportFile$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
file: File,
|
||||
reportFileId: string,
|
||||
): Observable<void>;
|
||||
|
||||
/**
|
||||
* Retrieves Access Intelligence summary data for an Organization within a date range.
|
||||
* @param orgId - the ID of the Organization to retrieve summary data for
|
||||
* @param startDate - the start of the date range (inclusive)
|
||||
* @param endDate - the end of the date range (inclusive)
|
||||
* @returns observable emitting an array of summary data records within the given date range
|
||||
*/
|
||||
abstract getSummaryDataByDateRange$(
|
||||
orgId: OrganizationId,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Observable<AccessReportSummaryApi[]>;
|
||||
|
||||
/**
|
||||
* Updates the summary data for an existing Access Intelligence report.
|
||||
* @param orgId - the ID of the Organization the report belongs to
|
||||
* @param reportId - the ID of the report to update
|
||||
* @param summaryData - the encrypted summary data to store on the report
|
||||
* @param metrics - optional map of metric names to their values
|
||||
* @returns observable emitting the updated Access Intelligence report
|
||||
*/
|
||||
abstract updateSummaryData$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
summaryData: string,
|
||||
metrics?: AccessReportMetricsApi,
|
||||
): Observable<AccessReportApi>;
|
||||
|
||||
/**
|
||||
* Updates the application data for an existing Access Intelligence report.
|
||||
* @param orgId - the ID of the Organization the report belongs to
|
||||
* @param reportId - the ID of the report to update
|
||||
* @param applicationData - the encrypted application data to store on the report
|
||||
* @returns observable emitting the updated Access Intelligence report
|
||||
*/
|
||||
abstract updateApplicationData$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
applicationData: string,
|
||||
): Observable<AccessReportApi>;
|
||||
|
||||
/**
|
||||
* Renews the upload link for an Access Intelligence report file. Used when a prior upload attempt failed or expired.
|
||||
* @param orgId - the ID of the Organization the report belongs to
|
||||
* @param reportId - the ID of the report whose upload link should be renewed
|
||||
* @returns observable emitting the renewed report file metadata, including a fresh upload URL
|
||||
*/
|
||||
abstract renewReportFileUploadLink$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<AccessReportFileApi>;
|
||||
|
||||
/**
|
||||
* Deletes an Access Intelligence report from the server.
|
||||
* @param orgId - the ID of the Organization the report belongs to
|
||||
* @param reportId - the ID of the report to delete
|
||||
* @returns observable that completes when the report has been deleted
|
||||
*/
|
||||
abstract deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable<void>;
|
||||
|
||||
/**
|
||||
* Self-hosted only. Downloads the file for an Access Intelligence report.
|
||||
* @param orgId - the ID of the Organization the report belongs to
|
||||
* @param reportId - the ID of the report whose file to download
|
||||
* @returns observable emitting the file blob and its filename
|
||||
*/
|
||||
abstract downloadReportFile$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<{ blob: Blob; fileName: string }>;
|
||||
|
||||
/**
|
||||
* Update the settings properties for an existing Access Intelligence report.
|
||||
* @param orgId - the ID of the Organization the report belongs to
|
||||
* @param reportId - the ID of the report to update
|
||||
* @param request - the data to update on the report
|
||||
* @returns observable emitting the updated Access Intelligence report
|
||||
*/
|
||||
abstract updateReportSettings$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
request: AccessReportSettingsUpdateRequest,
|
||||
): Observable<AccessReportApi>;
|
||||
}
|
||||
@ -0,0 +1,470 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { FileUploadType } from "@bitwarden/common/platform/enums";
|
||||
import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
AccessReportApi,
|
||||
AccessReportFileApi,
|
||||
AccessReportMetricsApi,
|
||||
AccessReportSummaryApi,
|
||||
} from "../../../models";
|
||||
import {
|
||||
AccessReportCreateRequest,
|
||||
AccessReportLegacyCreateRequest,
|
||||
AccessReportSettingsUpdateRequest,
|
||||
} from "../../abstractions/access-intelligence-api.service";
|
||||
|
||||
import { DefaultAccessIntelligenceApiService } from "./default-access-intelligence-api.service";
|
||||
|
||||
describe("DefaultAccessIntelligenceApiService", () => {
|
||||
let service: DefaultAccessIntelligenceApiService;
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
|
||||
const orgId = "org-123" as OrganizationId;
|
||||
const reportId = "report-456" as OrganizationReportId;
|
||||
const reportFileId = "file-789";
|
||||
const mockMetrics = new AccessReportMetricsApi({
|
||||
totalApplicationCount: 10,
|
||||
totalAtRiskApplicationCount: 3,
|
||||
totalCriticalApplicationCount: 2,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
totalMemberCount: 50,
|
||||
totalAtRiskMemberCount: 12,
|
||||
totalCriticalMemberCount: 5,
|
||||
totalCriticalAtRiskMemberCount: 2,
|
||||
totalPasswordCount: 200,
|
||||
totalAtRiskPasswordCount: 40,
|
||||
totalCriticalPasswordCount: 15,
|
||||
totalCriticalAtRiskPasswordCount: 5,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiService = mock<ApiService>();
|
||||
service = new DefaultAccessIntelligenceApiService(mockApiService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getLatestReport$", () => {
|
||||
it("should call GET /reports/organizations/{orgId}/latest and return AccessReportApi", async () => {
|
||||
const rawResponse = {
|
||||
id: reportId,
|
||||
organizationId: orgId,
|
||||
creationDate: "2024-01-01T00:00:00Z",
|
||||
reportData: "encrypted-reports",
|
||||
summaryData: "encrypted-summary",
|
||||
applicationData: "encrypted-apps",
|
||||
contentEncryptionKey: "enc-key",
|
||||
};
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const result = await firstValueFrom(service.getLatestReport$(orgId));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId}/latest`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AccessReportApi);
|
||||
expect(result.id).toBe(reportId);
|
||||
expect(result.organizationId).toBe(orgId);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(firstValueFrom(service.getLatestReport$(orgId))).rejects.toThrow(
|
||||
"Network error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReport$", () => {
|
||||
it("should call POST /reports/organizations/{orgId} and return AccessReportFileApi", async () => {
|
||||
const rawResponse = {
|
||||
reportFileUploadUrl: "https://storage.example.com/upload",
|
||||
fileUploadType: FileUploadType.Azure,
|
||||
reportResponse: {
|
||||
id: reportId,
|
||||
organizationId: orgId,
|
||||
creationDate: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const request: AccessReportCreateRequest = {
|
||||
fileSize: 1024,
|
||||
contentEncryptionKey: "enc-key",
|
||||
summaryData: "encrypted-summary",
|
||||
applicationData: "encrypted-apps",
|
||||
metrics: mockMetrics,
|
||||
};
|
||||
|
||||
const result = await firstValueFrom(service.createReport$(orgId, request));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId}`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AccessReportFileApi);
|
||||
expect(result.reportFileUploadUrl).toBe("https://storage.example.com/upload");
|
||||
expect(result.fileUploadType).toBe(FileUploadType.Azure);
|
||||
expect(result.reportResponse).toBeInstanceOf(AccessReportApi);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("API error"));
|
||||
|
||||
await expect(firstValueFrom(service.createReport$(orgId, {} as any))).rejects.toThrow(
|
||||
"API error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLegacyReport$", () => {
|
||||
it("should call POST /reports/organizations/{orgId} and return AccessReportApi", async () => {
|
||||
const rawResponse = {
|
||||
id: reportId,
|
||||
organizationId: orgId,
|
||||
creationDate: "2024-01-01T00:00:00Z",
|
||||
reportData: "encrypted-report-data",
|
||||
summaryData: "encrypted-summary",
|
||||
applicationData: "encrypted-apps",
|
||||
contentEncryptionKey: "enc-key",
|
||||
};
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const request: AccessReportLegacyCreateRequest = {
|
||||
reportData: "encrypted-report-data",
|
||||
contentEncryptionKey: "enc-key",
|
||||
summaryData: "encrypted-summary",
|
||||
applicationData: "encrypted-apps",
|
||||
metrics: mockMetrics,
|
||||
};
|
||||
|
||||
const result = await firstValueFrom(service.createLegacyReport$(orgId, request));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId}`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AccessReportApi);
|
||||
expect(result.id).toBe(reportId);
|
||||
expect(result.organizationId).toBe(orgId);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("API error"));
|
||||
|
||||
await expect(firstValueFrom(service.createLegacyReport$(orgId, {} as any))).rejects.toThrow(
|
||||
"API error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSummaryData$", () => {
|
||||
it("should call PATCH /reports/organizations/{orgId}/data/summary/{reportId} and return AccessReportApi", async () => {
|
||||
const rawResponse = {
|
||||
id: reportId,
|
||||
organizationId: orgId,
|
||||
creationDate: "2024-01-01T00:00:00Z",
|
||||
summaryData: "encrypted-summary",
|
||||
contentEncryptionKey: "enc-key",
|
||||
};
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const summaryData = "encrypted-summary-data";
|
||||
const metrics = mockMetrics;
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.updateSummaryData$(orgId, reportId, summaryData, metrics),
|
||||
);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId}/data/summary/${reportId}`,
|
||||
expect.objectContaining({ summaryData, metrics }),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AccessReportApi);
|
||||
expect(result.id).toBe(reportId);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Update failed"));
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.updateSummaryData$(orgId, reportId, "data")),
|
||||
).rejects.toThrow("Update failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateApplicationData$", () => {
|
||||
it("should call PATCH /reports/organizations/{orgId}/data/application/{reportId} and return AccessReportApi", async () => {
|
||||
const rawResponse = {
|
||||
id: reportId,
|
||||
organizationId: orgId,
|
||||
creationDate: "2024-01-01T00:00:00Z",
|
||||
applicationData: "encrypted-apps",
|
||||
contentEncryptionKey: "enc-key",
|
||||
};
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const applicationData = "encrypted-app-data";
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.updateApplicationData$(orgId, reportId, applicationData),
|
||||
);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId}/data/application/${reportId}`,
|
||||
expect.objectContaining({ applicationData }),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AccessReportApi);
|
||||
expect(result.id).toBe(reportId);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Update failed"));
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.updateApplicationData$(orgId, reportId, "data")),
|
||||
).rejects.toThrow("Update failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSummaryDataByDateRange$", () => {
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
|
||||
it("should call GET with date range params and return AccessReportSummaryApi[]", async () => {
|
||||
const rawResponse = [
|
||||
{ EncryptedData: "enc-data-1", EncryptionKey: "key-1", Date: "2024-01-15" },
|
||||
{ EncryptedData: "enc-data-2", EncryptionKey: "key-2", Date: "2024-01-20" },
|
||||
];
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.getSummaryDataByDateRange$(orgId, startDate, endDate),
|
||||
);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId}/data/summary?startDate=2024-01-01&endDate=2024-01-31`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBeInstanceOf(AccessReportSummaryApi);
|
||||
expect(result[0].encryptedData).toBe("enc-data-1");
|
||||
});
|
||||
|
||||
it("should return empty array when response is not an array", async () => {
|
||||
mockApiService.send.mockResolvedValue(null);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.getSummaryDataByDateRange$(orgId, startDate, endDate),
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array on 404 error", async () => {
|
||||
const notFoundError = new ErrorResponse({ Message: "Not found" }, 404);
|
||||
mockApiService.send.mockRejectedValue(notFoundError);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.getSummaryDataByDateRange$(orgId, startDate, endDate),
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should propagate non-404 errors", async () => {
|
||||
const serverError = new ErrorResponse({ Message: "Server error" }, 500);
|
||||
mockApiService.send.mockRejectedValue(serverError);
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.getSummaryDataByDateRange$(orgId, startDate, endDate)),
|
||||
).rejects.toBeInstanceOf(ErrorResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renewReportFileUpload$", () => {
|
||||
it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportFileApi", async () => {
|
||||
const rawResponse = {
|
||||
reportFileUploadUrl: "https://storage.example.com/renewed-upload",
|
||||
fileUploadType: FileUploadType.Azure,
|
||||
reportResponse: {
|
||||
id: reportId,
|
||||
organizationId: orgId,
|
||||
creationDate: "2024-01-01T00:00:00Z",
|
||||
contentEncryptionKey: "enc-key",
|
||||
},
|
||||
};
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const result = await firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId}/${reportId}/file/renew`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AccessReportFileApi);
|
||||
expect(result.reportResponse.id).toBe(reportId);
|
||||
expect(result.reportFileUploadUrl).toBe("https://storage.example.com/renewed-upload");
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Renew failed"));
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId)),
|
||||
).rejects.toThrow("Renew failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteReport$", () => {
|
||||
it("should call DELETE /reports/organizations/{orgId}/{reportId}", async () => {
|
||||
mockApiService.send.mockResolvedValue(undefined);
|
||||
|
||||
await firstValueFrom(service.deleteReport$(orgId, reportId));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
`/reports/organizations/${orgId}/${reportId}`,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Delete failed"));
|
||||
|
||||
await expect(firstValueFrom(service.deleteReport$(orgId, reportId))).rejects.toThrow(
|
||||
"Delete failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadReportFile$", () => {
|
||||
it("should call POST /reports/organizations/{orgId}/{reportId}/file/report-data with FormData", async () => {
|
||||
mockApiService.send.mockResolvedValue(undefined);
|
||||
|
||||
const file = new File(["file content"], "report.bin", { type: "application/octet-stream" });
|
||||
|
||||
await firstValueFrom(service.uploadReportFile$(orgId, reportId, file, reportFileId));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId}/${reportId}/file?reportFileId=${reportFileId}`,
|
||||
expect.any(FormData),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
const sentFormData: FormData = mockApiService.send.mock.calls[0][2] as FormData;
|
||||
expect(sentFormData.get("file")).toBeInstanceOf(File);
|
||||
expect((sentFormData.get("file") as File).name).toBe("report.bin");
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Upload failed"));
|
||||
|
||||
const file = new File(["content"], "report.bin");
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.uploadReportFile$(orgId, reportId, file, reportFileId)),
|
||||
).rejects.toThrow("Upload failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadReportFile$", () => {
|
||||
it("should call GET /reports/organizations/{orgId}/{reportId}/file/download and return blob with fileName", async () => {
|
||||
const blob = new Blob(["file content"], { type: "application/octet-stream" });
|
||||
const sendResponse = { blob, fileName: "report.bin" };
|
||||
mockApiService.send.mockResolvedValue(sendResponse);
|
||||
|
||||
const result = await firstValueFrom(service.downloadReportFile$(orgId, reportId));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId}/${reportId}/file/download`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result.blob).toBe(blob);
|
||||
expect(result.fileName).toBe("report.bin");
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Download failed"));
|
||||
|
||||
await expect(firstValueFrom(service.downloadReportFile$(orgId, reportId))).rejects.toThrow(
|
||||
"Download failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateReportSettings$", () => {
|
||||
it("should call PATCH /reports/organizations/{orgId}/{reportId} and return AccessReportApi", async () => {
|
||||
const rawResponse = {
|
||||
id: reportId,
|
||||
organizationId: orgId,
|
||||
creationDate: "2024-01-01T00:00:00Z",
|
||||
summaryData: "encrypted-summary",
|
||||
};
|
||||
mockApiService.send.mockResolvedValue(rawResponse);
|
||||
|
||||
const request: AccessReportSettingsUpdateRequest = {
|
||||
summaryData: "encrypted-summary",
|
||||
applicationData: "encrypted-apps",
|
||||
metrics: mockMetrics,
|
||||
};
|
||||
|
||||
const result = await firstValueFrom(service.updateReportSettings$(orgId, reportId, request));
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId}/${reportId}`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(AccessReportApi);
|
||||
expect(result.id).toBe(reportId);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
mockApiService.send.mockRejectedValue(new Error("Update failed"));
|
||||
|
||||
await expect(
|
||||
firstValueFrom(service.updateReportSettings$(orgId, reportId, {} as any)),
|
||||
).rejects.toThrow("Update failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,200 @@
|
||||
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 {
|
||||
AccessReportApi,
|
||||
AccessReportFileApi,
|
||||
AccessReportMetricsApi,
|
||||
AccessReportSummaryApi,
|
||||
} from "../../../models";
|
||||
import {
|
||||
AccessIntelligenceApiService,
|
||||
AccessReportCreateRequest,
|
||||
AccessReportLegacyCreateRequest,
|
||||
AccessReportSettingsUpdateRequest,
|
||||
} from "../../abstractions/access-intelligence-api.service";
|
||||
|
||||
export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiService {
|
||||
constructor(private apiService: ApiService) {
|
||||
super();
|
||||
}
|
||||
|
||||
getLatestReport$(orgId: OrganizationId): Observable<AccessReportApi> {
|
||||
const response = this.apiService.send(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId.toString()}/latest`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return from(response).pipe(map((res) => new AccessReportApi(res)));
|
||||
}
|
||||
|
||||
createReport$(
|
||||
orgId: OrganizationId,
|
||||
request: AccessReportCreateRequest,
|
||||
): Observable<AccessReportFileApi> {
|
||||
const response = this.apiService.send(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId.toString()}`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return from(response).pipe(map((response) => new AccessReportFileApi(response)));
|
||||
}
|
||||
|
||||
createLegacyReport$(
|
||||
orgId: OrganizationId,
|
||||
request: AccessReportLegacyCreateRequest,
|
||||
): Observable<AccessReportApi> {
|
||||
const response = this.apiService.send(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId.toString()}`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return from(response).pipe(map((res) => new AccessReportApi(res)));
|
||||
}
|
||||
|
||||
updateSummaryData$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
summaryData: string,
|
||||
metrics?: AccessReportMetricsApi,
|
||||
): Observable<AccessReportApi> {
|
||||
const response = this.apiService.send(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`,
|
||||
{ summaryData, metrics, reportId: reportId, organizationId: orgId },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return from(response).pipe(map((response) => new AccessReportApi(response)));
|
||||
}
|
||||
|
||||
updateApplicationData$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
applicationData: string,
|
||||
): Observable<AccessReportApi> {
|
||||
const response = this.apiService.send(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`,
|
||||
{ applicationData, id: reportId, organizationId: orgId },
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return from(response).pipe(map((response) => new AccessReportApi(response)));
|
||||
}
|
||||
|
||||
getSummaryDataByDateRange$(
|
||||
orgId: OrganizationId,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Observable<AccessReportSummaryApi[]> {
|
||||
const startDateStr = startDate.toISOString().split("T")[0];
|
||||
const endDateStr = endDate.toISOString().split("T")[0];
|
||||
const dbResponse = this.apiService.send(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId.toString()}/data/summary?startDate=${startDateStr}&endDate=${endDateStr}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return from(dbResponse).pipe(
|
||||
map((response: any[]) =>
|
||||
Array.isArray(response) ? response.map((r) => new AccessReportSummaryApi(r)) : [],
|
||||
),
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
return of([]);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
renewReportFileUploadLink$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<AccessReportFileApi> {
|
||||
const response = this.apiService.send(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId}/${reportId}/file/renew`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return from(response).pipe(map((res) => new AccessReportFileApi(res)));
|
||||
}
|
||||
|
||||
deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable<void> {
|
||||
const response = this.apiService.send(
|
||||
"DELETE",
|
||||
`/reports/organizations/${orgId}/${reportId}`,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
return from(response);
|
||||
}
|
||||
|
||||
uploadReportFile$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
file: File,
|
||||
reportFileId: string,
|
||||
): Observable<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, file.name);
|
||||
|
||||
const response = this.apiService.send(
|
||||
"POST",
|
||||
`/reports/organizations/${orgId}/${reportId}/file?reportFileId=${reportFileId}`,
|
||||
formData,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
return from(response);
|
||||
}
|
||||
|
||||
downloadReportFile$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
): Observable<{ blob: Blob; fileName: string }> {
|
||||
const response = this.apiService.send(
|
||||
"GET",
|
||||
`/reports/organizations/${orgId}/${reportId}/file/download`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return from(response);
|
||||
}
|
||||
|
||||
updateReportSettings$(
|
||||
orgId: OrganizationId,
|
||||
reportId: OrganizationReportId,
|
||||
request: AccessReportSettingsUpdateRequest,
|
||||
): Observable<AccessReportApi> {
|
||||
const response = this.apiService.send(
|
||||
"PATCH",
|
||||
`/reports/organizations/${orgId}/${reportId}`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return from(response).pipe(map((response) => new AccessReportApi(response)));
|
||||
}
|
||||
}
|
||||
@ -1694,11 +1694,19 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const responseType = response.headers.get("content-type");
|
||||
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
|
||||
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
|
||||
const responseIsBlob =
|
||||
responseType != null && responseType.indexOf("application/octet-stream") !== -1;
|
||||
if (hasResponse && response.status === HttpStatusCode.Ok && responseIsJson) {
|
||||
const responseJson = await response.json();
|
||||
return responseJson;
|
||||
} else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsCsv) {
|
||||
return await response.text();
|
||||
} else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsBlob) {
|
||||
const disposition = response.headers.get("Content-Disposition") ?? "";
|
||||
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
const fileName = match ? match[1].replace(/['"]/g, "") : "download";
|
||||
const blob = await response.blob();
|
||||
return { blob, fileName };
|
||||
} else if (
|
||||
response.status !== HttpStatusCode.Ok &&
|
||||
response.status !== HttpStatusCode.NoContent
|
||||
|
||||
Loading…
Reference in New Issue
Block a user