From aa3172ac6a0e3eadc851be0d2fde533d15a0192b Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 25 Mar 2026 16:48:43 -0700 Subject: [PATCH 01/21] add feature flag and abstract AccessIntelligenceApiService --- .../api/access-report-file-response.api.ts | 28 ++++++++ .../models/api/access-report.api.ts | 4 ++ .../api/organization-report-data.api.ts | 18 +++++ .../dirt/access-intelligence/models/index.ts | 2 + .../access-intelligence-api.service.ts | 71 +++++++++++++++++++ libs/common/src/enums/feature-flag.enum.ts | 2 + 6 files changed, 125 insertions(+) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts new file mode 100644 index 000000000000..ca54e352f218 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts @@ -0,0 +1,28 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +import { AccessReportApi } from "./access-report.api"; + +/** + * API response model returned by createReport (V2) and updateSummary (V2 when requiresNewFileUpload: true). + * Maps OrganizationReportFileResponseModel from the server. + */ +export class AccessReportFileResponseApi extends BaseResponse { + reportFileUploadUrl: string = ""; + reportResponse: AccessReportApi = new AccessReportApi(); + fileUploadType: number = 0; // FileUploadType: 0 = Direct, 1 = Azure + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.reportFileUploadUrl = this.getResponseProperty("reportFileUploadUrl") ?? ""; + this.fileUploadType = this.getResponseProperty("fileUploadType") ?? 0; + + const reportResponse = this.getResponseProperty("reportResponse"); + if (reportResponse != null) { + this.reportResponse = new AccessReportApi(reportResponse); + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts index 0470f4331647..06615a962987 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts @@ -24,6 +24,8 @@ export class AccessReportApi extends BaseResponse { memberRegistry: string = ""; creationDate: string = ""; contentEncryptionKey: string = ""; + reportFile?: string; + reportFileDownloadUrl?: string; constructor(data: any = null) { super(data); @@ -39,6 +41,8 @@ export class AccessReportApi extends BaseResponse { this.summary = this.getResponseProperty("summaryData"); this.memberRegistry = this.getResponseProperty("memberRegistry") ?? ""; this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); + this.reportFile = this.getResponseProperty("reportFile") ?? undefined; + this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined; // Use when individual values are encrypted // const summary = this.getResponseProperty("summaryData"); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts new file mode 100644 index 000000000000..12a4667931e1 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +/** + * API response model returned by GET /data/report/{reportId}. + * Maps OrganizationReportDataResponseModel from the server. + */ +export class OrganizationReportDataApi extends BaseResponse { + reportData: string | null = null; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.reportData = this.getResponseProperty("reportData") ?? null; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts index 46f30be4848e..e39a1338e195 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts @@ -1,9 +1,11 @@ // API layer export * from "./api/access-report.api"; +export * from "./api/access-report-file-response.api"; 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/organization-report-data.api"; // Data layer export * from "./data/access-report.data"; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts new file mode 100644 index 000000000000..c84f64b6cd9e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -0,0 +1,71 @@ +import { Observable } from "rxjs"; + +import { OrganizationId } from "@bitwarden/sdk-internal"; + +import { + AccessReportApi, + AccessReportFileResponseApi, + AccessReportSummaryApi, + OrganizationReportDataApi, +} from "../../models"; + +export abstract class AccessIntelligenceApiService { + /** GET /reports/organizations/{orgId}/latest */ + abstract getLatestReport$(orgId: OrganizationId): Observable; + + /** + * POST /reports/organizations/{orgId} + * V2: returns upload URL + report metadata. V1: returns report metadata only. + */ + abstract createReport$( + orgId: OrganizationId, + request: AccessReportApi, + fileSize?: number, + ): Observable; + + /** + * POST /reports/organizations/{orgId}/{reportId}/file/report-data + * Self-hosted only. Uploads report data file via multipart form data. + */ + abstract uploadReportFile$( + orgId: OrganizationId, + reportId: string, + file: File, + reportFileId: string, + ): Observable; + + /** GET /reports/organizations/{orgId}/data/summary?startDate=&endDate= */ + abstract getSummaryDataByDateRange$( + orgId: OrganizationId, + startDate: Date, + endDate: Date, + ): Observable; + + /** PATCH /reports/organizations/{orgId}/data/summary/{reportId} */ + abstract updateSummary$( + orgId: OrganizationId, + reportId: string, + summaryData: string | null, + metrics?: Record, + ): Observable; + + /** GET /reports/organizations/{orgId}/data/report/{reportId} */ + abstract getReportData$( + orgId: OrganizationId, + reportId: string, + ): Observable; + + /** PATCH /reports/organizations/{orgId}/data/report/{reportId} */ + abstract updateReportData$( + orgId: OrganizationId, + reportId: string, + reportData: string | null, + ): Observable; + + /** PATCH /reports/organizations/{orgId}/data/application/{reportId} */ + abstract updateApplicationData$( + orgId: OrganizationId, + reportId: string, + applicationData: string | null, + ): Observable; +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 2c1d90c1d6f2..9437ea96e839 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -64,6 +64,7 @@ export enum FeatureFlag { Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", AccessIntelligenceTrendChart = "pm-26961-access-intelligence-trend-chart", AccessIntelligenceNewArchitecture = "pm-31936-access-intelligence-new-architecture", + AccessIntelligenceAzureFileStorage = "pm-31920-access-intelligence-azure-file-storage", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -126,6 +127,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.Milestone11AppPageImprovements]: FALSE, [FeatureFlag.AccessIntelligenceTrendChart]: FALSE, [FeatureFlag.AccessIntelligenceNewArchitecture]: FALSE, + [FeatureFlag.AccessIntelligenceAzureFileStorage]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, From f18404b4e164bd464a7221e046658effd7b70918 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 26 Mar 2026 12:18:22 -0700 Subject: [PATCH 02/21] add default implementation of AccessIntelligenceApiService --- .../models/api/access-report.api.ts | 2 + .../access-intelligence-api.service.ts | 23 +-- ...default-access-intelligence-api.service.ts | 135 ++++++++++++++++++ 3 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts index 06615a962987..d63a4213101a 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts @@ -26,6 +26,7 @@ export class AccessReportApi extends BaseResponse { contentEncryptionKey: string = ""; reportFile?: string; reportFileDownloadUrl?: string; + fileSize?: number; constructor(data: any = null) { super(data); @@ -43,6 +44,7 @@ export class AccessReportApi extends BaseResponse { this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); this.reportFile = this.getResponseProperty("reportFile") ?? undefined; this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined; + this.fileSize = this.getResponseProperty("fileSize") ?? undefined; // Use when individual values are encrypted // const summary = this.getResponseProperty("summaryData"); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index c84f64b6cd9e..f39bdaf230ce 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -2,12 +2,7 @@ import { Observable } from "rxjs"; import { OrganizationId } from "@bitwarden/sdk-internal"; -import { - AccessReportApi, - AccessReportFileResponseApi, - AccessReportSummaryApi, - OrganizationReportDataApi, -} from "../../models"; +import { AccessReportApi, AccessReportFileResponseApi, AccessReportSummaryApi } from "../../models"; export abstract class AccessIntelligenceApiService { /** GET /reports/organizations/{orgId}/latest */ @@ -20,7 +15,6 @@ export abstract class AccessIntelligenceApiService { abstract createReport$( orgId: OrganizationId, request: AccessReportApi, - fileSize?: number, ): Observable; /** @@ -42,26 +36,13 @@ export abstract class AccessIntelligenceApiService { ): Observable; /** PATCH /reports/organizations/{orgId}/data/summary/{reportId} */ - abstract updateSummary$( + abstract updateSummaryData$( orgId: OrganizationId, reportId: string, summaryData: string | null, metrics?: Record, ): Observable; - /** GET /reports/organizations/{orgId}/data/report/{reportId} */ - abstract getReportData$( - orgId: OrganizationId, - reportId: string, - ): Observable; - - /** PATCH /reports/organizations/{orgId}/data/report/{reportId} */ - abstract updateReportData$( - orgId: OrganizationId, - reportId: string, - reportData: string | null, - ): Observable; - /** PATCH /reports/organizations/{orgId}/data/application/{reportId} */ abstract updateApplicationData$( orgId: OrganizationId, diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts new file mode 100644 index 000000000000..3579f9a00ed5 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -0,0 +1,135 @@ +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 } from "@bitwarden/sdk-internal"; + +import { + AccessReportApi, + AccessReportFileResponseApi, + AccessReportSummaryApi, +} from "../../../models"; +import { AccessIntelligenceApiService } from "../../abstractions/access-intelligence-api.service"; + +export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiService { + constructor(private apiService: ApiService) { + super(); + } + + getLatestReport$(orgId: OrganizationId): Observable { + 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: AccessReportApi, + ): Observable { + const response = this.apiService.send( + "GET", + `/reports/organizations/${orgId.toString()}/latest`, + request, + true, + true, + ); + return from(response).pipe( + map((res) => { + const fileResponse = new AccessReportFileResponseApi(res); + + // response for file upload requests + if (fileResponse.reportFileUploadUrl != "") { + return fileResponse; + } + + return new AccessReportApi(res); + }), + ); + } + + updateSummaryData$( + orgId: OrganizationId, + reportId: string, + summaryData: string | null, + metrics?: Record, + ): Observable { + 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: string, + applicationData: string | null, + ): Observable { + 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 { + 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); + }), + ); + } + + uploadReportFile$( + orgId: OrganizationId, + reportId: string, + file: File, + reportFileId: string, + ): Observable { + const formData = new FormData(); + formData.append("file", file, file.name); + + const response = this.apiService.send( + "POST", + `/reports/organizations/${orgId}/${reportId}/file/report-data?reportFileId=${reportFileId}`, + formData, + true, + false, + ); + + return from(response); + } +} From c7ec428618366c5b2732df2880c3cdadd1b0fe40 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 31 Mar 2026 14:35:24 -0700 Subject: [PATCH 03/21] remove feature flag for now --- libs/common/src/enums/feature-flag.enum.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9437ea96e839..2c1d90c1d6f2 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -64,7 +64,6 @@ export enum FeatureFlag { Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", AccessIntelligenceTrendChart = "pm-26961-access-intelligence-trend-chart", AccessIntelligenceNewArchitecture = "pm-31936-access-intelligence-new-architecture", - AccessIntelligenceAzureFileStorage = "pm-31920-access-intelligence-azure-file-storage", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -127,7 +126,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.Milestone11AppPageImprovements]: FALSE, [FeatureFlag.AccessIntelligenceTrendChart]: FALSE, [FeatureFlag.AccessIntelligenceNewArchitecture]: FALSE, - [FeatureFlag.AccessIntelligenceAzureFileStorage]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, From ef851f1ab91e4b6b5adb33e974c56ded365e4fea Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 31 Mar 2026 16:46:18 -0700 Subject: [PATCH 04/21] update api service to use models --- .../models/api/access-report-create.api.ts | 13 +++++++++ .../api/access-report-file-response.api.ts | 28 ------------------ .../models/api/access-report-file.api.ts | 29 +++++++++++++++++++ .../models/api/access-report.api.ts | 10 ++++--- .../api/organization-report-data.api.ts | 18 ------------ .../models/api/report-file.api.ts | 20 +++++++++++++ .../dirt/access-intelligence/models/index.ts | 4 +-- .../access-intelligence-api.service.ts | 11 +++++-- ...default-access-intelligence-api.service.ts | 20 ++++--------- 9 files changed, 83 insertions(+), 70 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts new file mode 100644 index 000000000000..68d53fd54e05 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts @@ -0,0 +1,13 @@ +import { AccessReportMetricsApi } from "./access-report-metrics.api"; + +/** + * Request body for POST /reports/organizations/{organizationId} + * + */ +export class AccessReportCreateApi { + contentEncryptionKey?: string; + summaryData?: string; + applicationData?: string; + metrics?: AccessReportMetricsApi; + fileSize?: number; +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts deleted file mode 100644 index ca54e352f218..000000000000 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseResponse } from "@bitwarden/common/models/response/base.response"; - -import { AccessReportApi } from "./access-report.api"; - -/** - * API response model returned by createReport (V2) and updateSummary (V2 when requiresNewFileUpload: true). - * Maps OrganizationReportFileResponseModel from the server. - */ -export class AccessReportFileResponseApi extends BaseResponse { - reportFileUploadUrl: string = ""; - reportResponse: AccessReportApi = new AccessReportApi(); - fileUploadType: number = 0; // FileUploadType: 0 = Direct, 1 = Azure - - constructor(data: any = null) { - super(data); - if (data == null) { - return; - } - - this.reportFileUploadUrl = this.getResponseProperty("reportFileUploadUrl") ?? ""; - this.fileUploadType = this.getResponseProperty("fileUploadType") ?? 0; - - const reportResponse = this.getResponseProperty("reportResponse"); - if (reportResponse != null) { - this.reportResponse = new AccessReportApi(reportResponse); - } - } -} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts new file mode 100644 index 000000000000..9c782164682e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts @@ -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 a report with the Access Intelligence V2 feature flag + * enabled. Contains a presigned upload URL for the report file along with the created 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; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts index d63a4213101a..54924a1d0541 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts @@ -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,9 +26,8 @@ export class AccessReportApi extends BaseResponse { memberRegistry: string = ""; creationDate: string = ""; contentEncryptionKey: string = ""; - reportFile?: string; + reportFile?: ReportFileApi; reportFileDownloadUrl?: string; - fileSize?: number; constructor(data: any = null) { super(data); @@ -42,9 +43,10 @@ export class AccessReportApi extends BaseResponse { this.summary = this.getResponseProperty("summaryData"); this.memberRegistry = this.getResponseProperty("memberRegistry") ?? ""; this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); - this.reportFile = this.getResponseProperty("reportFile") ?? undefined; this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined; - this.fileSize = this.getResponseProperty("fileSize") ?? 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"); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts deleted file mode 100644 index 12a4667931e1..000000000000 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BaseResponse } from "@bitwarden/common/models/response/base.response"; - -/** - * API response model returned by GET /data/report/{reportId}. - * Maps OrganizationReportDataResponseModel from the server. - */ -export class OrganizationReportDataApi extends BaseResponse { - reportData: string | null = null; - - constructor(data: any = null) { - super(data); - if (data == null) { - return; - } - - this.reportData = this.getResponseProperty("reportData") ?? null; - } -} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts new file mode 100644 index 000000000000..62e5acb122cc --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts @@ -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 | undefined; + 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") ?? undefined; + this.fileName = this.getResponseProperty("fileName") ?? ""; + this.size = Number(this.getResponseProperty("size") ?? 0); + this.validated = this.getResponseProperty("validated") ?? false; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts index e39a1338e195..c5a4c864570e 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts @@ -1,11 +1,11 @@ // API layer export * from "./api/access-report.api"; -export * from "./api/access-report-file-response.api"; 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/organization-report-data.api"; +export * from "./api/access-report-create.api"; +export * from "./api/access-report-file.api"; // Data layer export * from "./data/access-report.data"; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index f39bdaf230ce..c7325417c451 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -2,7 +2,12 @@ import { Observable } from "rxjs"; import { OrganizationId } from "@bitwarden/sdk-internal"; -import { AccessReportApi, AccessReportFileResponseApi, AccessReportSummaryApi } from "../../models"; +import { + AccessReportApi, + AccessReportCreateApi, + AccessReportFileApi, + AccessReportSummaryApi, +} from "../../models"; export abstract class AccessIntelligenceApiService { /** GET /reports/organizations/{orgId}/latest */ @@ -14,8 +19,8 @@ export abstract class AccessIntelligenceApiService { */ abstract createReport$( orgId: OrganizationId, - request: AccessReportApi, - ): Observable; + request: AccessReportCreateApi, + ): Observable; /** * POST /reports/organizations/{orgId}/{reportId}/file/report-data diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index 3579f9a00ed5..1a1ce63848a9 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -6,8 +6,9 @@ import { OrganizationId } from "@bitwarden/sdk-internal"; import { AccessReportApi, - AccessReportFileResponseApi, + AccessReportFileApi, AccessReportSummaryApi, + AccessReportCreateApi, } from "../../../models"; import { AccessIntelligenceApiService } from "../../abstractions/access-intelligence-api.service"; @@ -29,8 +30,8 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe createReport$( orgId: OrganizationId, - request: AccessReportApi, - ): Observable { + request: AccessReportCreateApi, + ): Observable { const response = this.apiService.send( "GET", `/reports/organizations/${orgId.toString()}/latest`, @@ -38,18 +39,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe true, true, ); - return from(response).pipe( - map((res) => { - const fileResponse = new AccessReportFileResponseApi(res); - - // response for file upload requests - if (fileResponse.reportFileUploadUrl != "") { - return fileResponse; - } - - return new AccessReportApi(res); - }), - ); + return from(response).pipe(map((response) => new AccessReportFileApi(response))); } updateSummaryData$( From cad3633765cf532f616f7a215c351479e845e141 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 31 Mar 2026 16:58:56 -0700 Subject: [PATCH 05/21] add tests, fix create endpoint --- .../access-intelligence-api.service.ts | 2 +- ...lt-access-intelligence-api.service.spec.ts | 308 ++++++++++++++++++ ...default-access-intelligence-api.service.ts | 6 +- 3 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index c7325417c451..907b1b496409 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { OrganizationId } from "@bitwarden/sdk-internal"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AccessReportApi, diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts new file mode 100644 index 000000000000..db4463ab5ee3 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts @@ -0,0 +1,308 @@ +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 } from "@bitwarden/common/types/guid"; + +import { + AccessReportApi, + AccessReportCreateApi, + AccessReportFileApi, + AccessReportSummaryApi, +} from "../../../models"; + +import { DefaultAccessIntelligenceApiService } from "./default-access-intelligence-api.service"; + +describe("DefaultAccessIntelligenceApiService", () => { + let service: DefaultAccessIntelligenceApiService; + let mockApiService: MockProxy; + + const orgId = "org-123" as OrganizationId; + const reportId = "report-456"; + const reportFileId = "file-789"; + + beforeEach(() => { + mockApiService = mock(); + 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 = new AccessReportCreateApi(); + request.fileSize = 1024; + request.contentEncryptionKey = "enc-key"; + + 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")); + + const request = new AccessReportCreateApi(); + + await expect(firstValueFrom(service.createReport$(orgId, request))).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 = { totalApplicationCount: 5, totalAtRiskApplicationCount: 2 }; + + 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 send null summaryData when not provided", async () => { + const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; + mockApiService.send.mockResolvedValue(rawResponse); + + await firstValueFrom(service.updateSummaryData$(orgId, reportId, null)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + expect.any(String), + expect.objectContaining({ summaryData: null }), + true, + true, + ); + }); + + 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 send null applicationData when not provided", async () => { + const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; + mockApiService.send.mockResolvedValue(rawResponse); + + await firstValueFrom(service.updateApplicationData$(orgId, reportId, null)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + expect.any(String), + expect.objectContaining({ applicationData: null }), + true, + true, + ); + }); + + 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("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/report-data?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"); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index 1a1ce63848a9..f89ac0d0b60c 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -2,7 +2,7 @@ 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 } from "@bitwarden/sdk-internal"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -33,8 +33,8 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe request: AccessReportCreateApi, ): Observable { const response = this.apiService.send( - "GET", - `/reports/organizations/${orgId.toString()}/latest`, + "POST", + `/reports/organizations/${orgId.toString()}`, request, true, true, From a068d8b996572f2d6c69c3d9b53289eb44255934 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 1 Apr 2026 11:08:22 -0700 Subject: [PATCH 06/21] remove null values from summary and appplication update endpoints --- .../access-intelligence-api.service.ts | 4 +-- ...lt-access-intelligence-api.service.spec.ts | 30 ------------------- ...default-access-intelligence-api.service.ts | 4 +-- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index 907b1b496409..35290ed2e779 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -44,7 +44,7 @@ export abstract class AccessIntelligenceApiService { abstract updateSummaryData$( orgId: OrganizationId, reportId: string, - summaryData: string | null, + summaryData: string, metrics?: Record, ): Observable; @@ -52,6 +52,6 @@ export abstract class AccessIntelligenceApiService { abstract updateApplicationData$( orgId: OrganizationId, reportId: string, - applicationData: string | null, + applicationData: string, ): Observable; } diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts index db4463ab5ee3..356e11c62a45 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts @@ -140,21 +140,6 @@ describe("DefaultAccessIntelligenceApiService", () => { expect(result.id).toBe(reportId); }); - it("should send null summaryData when not provided", async () => { - const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; - mockApiService.send.mockResolvedValue(rawResponse); - - await firstValueFrom(service.updateSummaryData$(orgId, reportId, null)); - - expect(mockApiService.send).toHaveBeenCalledWith( - "PATCH", - expect.any(String), - expect.objectContaining({ summaryData: null }), - true, - true, - ); - }); - it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("Update failed")); @@ -192,21 +177,6 @@ describe("DefaultAccessIntelligenceApiService", () => { expect(result.id).toBe(reportId); }); - it("should send null applicationData when not provided", async () => { - const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; - mockApiService.send.mockResolvedValue(rawResponse); - - await firstValueFrom(service.updateApplicationData$(orgId, reportId, null)); - - expect(mockApiService.send).toHaveBeenCalledWith( - "PATCH", - expect.any(String), - expect.objectContaining({ applicationData: null }), - true, - true, - ); - }); - it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("Update failed")); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index f89ac0d0b60c..74fcca845d4f 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -45,7 +45,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateSummaryData$( orgId: OrganizationId, reportId: string, - summaryData: string | null, + summaryData: string, metrics?: Record, ): Observable { const response = this.apiService.send( @@ -62,7 +62,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateApplicationData$( orgId: OrganizationId, reportId: string, - applicationData: string | null, + applicationData: string, ): Observable { const response = this.apiService.send( "PATCH", From fb7c07b34ff46c3ace472eccb4df30d029331561 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 1 Apr 2026 11:39:15 -0700 Subject: [PATCH 07/21] remove bad comment --- .../services/abstractions/access-intelligence-api.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index 35290ed2e779..e7e2666c2fb6 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -15,7 +15,6 @@ export abstract class AccessIntelligenceApiService { /** * POST /reports/organizations/{orgId} - * V2: returns upload URL + report metadata. V1: returns report metadata only. */ abstract createReport$( orgId: OrganizationId, From 07d7a1e88db2e4089435bb7d3a9bfcb9ec5fd9d0 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 27 Mar 2026 17:12:05 -0700 Subject: [PATCH 08/21] start implementation of saving report as file. handle azure and direct upload cases --- .../models/api/access-report.api.ts | 12 +- .../models/data/access-report.data.ts | 6 +- .../file-report-persistence.service.ts | 211 ++++++++++++++++++ 3 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts index 54924a1d0541..679894d8a7be 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts @@ -20,9 +20,9 @@ import { ReportFileApi } from "./report-file.api"; export class AccessReportApi extends BaseResponse { id: string = ""; organizationId: string = ""; - reports: string = ""; - applications: string = ""; - summary: string = ""; + reportData: string = ""; + applicationData: string = ""; + summaryData: string = ""; memberRegistry: string = ""; creationDate: string = ""; contentEncryptionKey: string = ""; @@ -38,9 +38,9 @@ export class AccessReportApi extends BaseResponse { this.id = this.getResponseProperty("id"); this.organizationId = this.getResponseProperty("organizationId"); this.creationDate = this.getResponseProperty("creationDate"); - this.reports = this.getResponseProperty("reportData"); - this.applications = this.getResponseProperty("applicationData"); - this.summary = this.getResponseProperty("summaryData"); + this.reportData = this.getResponseProperty("reportData"); + this.applicationData = this.getResponseProperty("applicationData"); + this.summaryData = this.getResponseProperty("summaryData"); this.memberRegistry = this.getResponseProperty("memberRegistry") ?? ""; this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/data/access-report.data.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/data/access-report.data.ts index 12848cb6feb8..d35ef2516b01 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/data/access-report.data.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/data/access-report.data.ts @@ -31,9 +31,9 @@ export class AccessReportData { this.id = response.id; this.organizationId = response.organizationId; - this.reports = response.reports; - this.applications = response.applications; - this.summary = response.summary; + this.reports = response.reportData; + this.applications = response.applicationData; + this.summary = response.summaryData; this.creationDate = response.creationDate; this.contentEncryptionKey = response.contentEncryptionKey; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts new file mode 100644 index 000000000000..e453716d2a2b --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -0,0 +1,211 @@ +import { firstValueFrom, forkJoin, from, map, Observable, of, switchMap, throwError } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { FileUploadService } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; +import { FileUploadType } from "@bitwarden/common/platform/enums"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { OrganizationReportId, OrganizationId } from "@bitwarden/common/types/guid"; +import { LogService } from "@bitwarden/logging"; + +import { + AccessReportView, + AccessReport, + AccessReportData, + AccessReportApi, + AccessReportMetricsApi, +} from "../../../models"; +import { AccessIntelligenceApiService } from "../../abstractions/access-intelligence-api.service"; +import { AccessReportEncryptionService } from "../../abstractions/access-report-encryption.service"; +import { ReportPersistenceService } from "../../abstractions/report-persistence.service"; + +export class FileReportPersistenceService extends ReportPersistenceService { + constructor( + private accessIntelligenceApiService: AccessIntelligenceApiService, + private riskInsightsEncryptionService: AccessReportEncryptionService, + private accountService: AccountService, + private logService: LogService, + private fileUploadService: FileUploadService, + ) { + super(); + } + + saveReport$( + view: AccessReportView, + organizationId: OrganizationId, + ): Observable<{ id: OrganizationReportId; contentEncryptionKey: EncString }> { + this.logService.debug("[FileReportPersistenceService] Saving report", { + organizationId, + }); + + return from(firstValueFrom(getUserId(this.accountService.activeAccount$))).pipe( + switchMap((userId) => { + if (!userId) { + throw new Error("User ID not found"); + } + + // Encrypt view to domain model + return from( + AccessReport.fromView(view, this.riskInsightsEncryptionService, { + organizationId, + userId, + }), + ).pipe( + switchMap((domain) => { + if (!domain.contentEncryptionKey) { + return throwError(() => new Error("Report encryption key not found")); + } + + // Extract encrypted data from domain model + const data = domain.toData(); + + const reportFile = new File([data.reports], "report-data.json", { + type: "application/json", + }); + + const request = new AccessReportApi(); + request.applicationData = data.applications; + request.summaryData = data.summary; + request.contentEncryptionKey = data.contentEncryptionKey; + request.fileSize = reportFile.size; + + return this.accessIntelligenceApiService.createReport$(organizationId, request).pipe( + map((result) => ({ + result, + reportFile, + contentEncryptionKey: domain.contentEncryptionKey!, + })), + ); + }), + switchMap(({ result, reportFile, contentEncryptionKey }) => { + const reportId = result.reportResponse.id as OrganizationReportId; + const reportFileId = result.reportResponse.reportFile ?? ""; + + const upload$: Observable = + result.fileUploadType === FileUploadType.Azure + ? from(reportFile.arrayBuffer()).pipe( + switchMap((buffer) => + from( + this.fileUploadService.upload( + { url: result.reportFileUploadUrl, fileUploadType: FileUploadType.Azure }, + new EncString(""), + { buffer: new Uint8Array(buffer) } as unknown as EncArrayBuffer, + { + postDirect: () => Promise.resolve(), + renewFileUploadUrl: () => Promise.resolve(result.reportFileUploadUrl), + rollback: () => Promise.resolve(), + }, + ), + ), + ), + ) + : this.accessIntelligenceApiService.uploadReportFile$( + organizationId, + reportId, + reportFile, + reportFileId, + ); + + return upload$.pipe(map(() => ({ id: reportId, contentEncryptionKey }))); + }), + ); + }), + ); + } + + saveApplicationMetadata$(view: AccessReportView): Observable { + this.logService.debug("[DefaultReportPersistenceService] Saving application metadata", { + reportId: view.id, + organizationId: view.organizationId, + applicationCount: view.applications.length, + }); + + return from(firstValueFrom(getUserId(this.accountService.activeAccount$))).pipe( + switchMap((userId) => { + if (!userId) { + throw new Error("User ID not found"); + } + + // Encrypt view to domain model + return from( + AccessReport.fromView(view, this.riskInsightsEncryptionService, { + organizationId: view.organizationId, + userId, + }), + ).pipe( + switchMap((domain) => { + const data = domain.toData(); + + const updateApplicationsCall = this.accessIntelligenceApiService.updateApplicationData$( + view.organizationId, + view.id, + data.applications, + ); + + const metrics = view.toMetrics().toAccessReportMetricsData(); + + const updateSummaryCall = this.accessIntelligenceApiService.updateSummaryData$( + view.organizationId, + view.id, + data.summary, + metrics as AccessReportMetricsApi, + ); + + return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe( + map(() => undefined as void), + ); + }), + ); + }), + ); + } + + // TODO Rename to loadLastReport$ + loadReport$( + organizationId: OrganizationId, + ): Observable<{ report: AccessReportView; hadLegacyBlobs: boolean } | null> { + this.logService.debug("[DefaultReportPersistenceService] Loading report", { organizationId }); + + return from(firstValueFrom(getUserId(this.accountService.activeAccount$))).pipe( + switchMap((userId) => { + if (!userId) { + throw new Error("User ID not found"); + } + + return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( + switchMap((apiResponse) => { + if (!apiResponse) { + return of(null); + } + + if ( + !apiResponse.contentEncryptionKey || + !apiResponse.contentEncryptionKey.encryptedString || + apiResponse.contentEncryptionKey.encryptedString === "" + ) { + throw new Error("Report encryption key not found"); + } + + // Convert API → Data → Domain → View (following 4-layer architecture) + const data = new AccessReportData(); + data.id = apiResponse.id; + data.organizationId = apiResponse.organizationId; + data.reports = apiResponse.reportData.encryptedString ?? ""; + data.summary = apiResponse.summaryData.encryptedString ?? ""; + data.applications = apiResponse.applicationData.encryptedString ?? ""; + data.creationDate = apiResponse.creationDate.toISOString(); + data.contentEncryptionKey = apiResponse.contentEncryptionKey.encryptedString ?? ""; + + const domain = new AccessReport(data); + + // Domain handles its own decryption + return from( + domain.decrypt(this.riskInsightsEncryptionService, { organizationId, userId }), + ).pipe(map(({ view, hadLegacyBlobs }) => ({ report: view, hadLegacyBlobs }))); + }), + ); + }), + ); + } +} From 2fd007c58b7d0b7ba506b2e79ba733ed7d5de0fd Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 27 Mar 2026 18:20:25 -0700 Subject: [PATCH 09/21] implement loadReport$, conditionally fetch file from either azure blob or server self host. add report data from file to the accessreport data model --- .../access-intelligence-api.service.ts | 6 ++ ...default-access-intelligence-api.service.ts | 26 ++++++ .../file-report-persistence.service.ts | 85 +++++++++++++------ 3 files changed, 93 insertions(+), 24 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index e7e2666c2fb6..816ace19cf1b 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -32,6 +32,12 @@ export abstract class AccessIntelligenceApiService { reportFileId: string, ): Observable; + /** GET report data file from a blob storage download URL. Returns raw file text. */ + abstract downloadReportFile$(url: string): Observable; + + /** GET /reports/organizations/{orgId}/{reportId}/file/download — Direct-upload file retrieval. */ + abstract getReportFileData$(orgId: OrganizationId, reportId: string): Observable; + /** GET /reports/organizations/{orgId}/data/summary?startDate=&endDate= */ abstract getSummaryDataByDateRange$( orgId: OrganizationId, diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index 74fcca845d4f..0dc472eda687 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -122,4 +122,30 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe return from(response); } + + downloadReportFile$(url: string): Observable { + return from( + this.apiService + .nativeFetch(new Request(url, { cache: "no-store" })) + .then(async (response) => { + if (response.status !== 200) { + throw new Error(`Failed to download report file: ${response.status}`); + } + const buffer = await response.arrayBuffer(); + return new TextDecoder().decode(buffer); + }), + ); + } + + getReportFileData$(orgId: OrganizationId, reportId: string): Observable { + return from( + this.apiService.send( + "GET", + `/reports/organizations/${orgId}/${reportId}/file/download`, + null, + true, + true, + ) as Promise, + ); + } } diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts index e453716d2a2b..0ae20bf2b445 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -1,8 +1,19 @@ -import { firstValueFrom, forkJoin, from, map, Observable, of, switchMap, throwError } from "rxjs"; +import { + catchError, + firstValueFrom, + forkJoin, + from, + map, + Observable, + of, + switchMap, + throwError, +} from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { FileUploadService } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { FileUploadType } from "@bitwarden/common/platform/enums"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; @@ -161,7 +172,6 @@ export class FileReportPersistenceService extends ReportPersistenceService { ); } - // TODO Rename to loadLastReport$ loadReport$( organizationId: OrganizationId, ): Observable<{ report: AccessReportView; hadLegacyBlobs: boolean } | null> { @@ -173,36 +183,63 @@ export class FileReportPersistenceService extends ReportPersistenceService { throw new Error("User ID not found"); } - return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( + return this.accessIntelligenceApiService.getLatestReport$(organizationId).pipe( + catchError((error: unknown) => { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return of(null); + } + return throwError(() => error); + }), switchMap((apiResponse) => { if (!apiResponse) { return of(null); } - if ( - !apiResponse.contentEncryptionKey || - !apiResponse.contentEncryptionKey.encryptedString || - apiResponse.contentEncryptionKey.encryptedString === "" - ) { + if (!apiResponse.contentEncryptionKey || apiResponse.contentEncryptionKey === "") { throw new Error("Report encryption key not found"); } - // Convert API → Data → Domain → View (following 4-layer architecture) - const data = new AccessReportData(); - data.id = apiResponse.id; - data.organizationId = apiResponse.organizationId; - data.reports = apiResponse.reportData.encryptedString ?? ""; - data.summary = apiResponse.summaryData.encryptedString ?? ""; - data.applications = apiResponse.applicationData.encryptedString ?? ""; - data.creationDate = apiResponse.creationDate.toISOString(); - data.contentEncryptionKey = apiResponse.contentEncryptionKey.encryptedString ?? ""; - - const domain = new AccessReport(data); - - // Domain handles its own decryption - return from( - domain.decrypt(this.riskInsightsEncryptionService, { organizationId, userId }), - ).pipe(map(({ view, hadLegacyBlobs }) => ({ report: view, hadLegacyBlobs }))); + // V2: reportData lives in a file. Determine download strategy from the URL: + // - Azure blob URL → unauthenticated GET (SAS token in URL handles auth) + // - Server URL → authenticated API call + // V1 fallback: reportData is inline in the response. + let reportData$: Observable; + if (apiResponse.reportFileDownloadUrl) { + const isAzure = new URL(apiResponse.reportFileDownloadUrl).hostname.includes( + "blob.core.windows.net", + ); + reportData$ = isAzure + ? this.accessIntelligenceApiService.downloadReportFile$( + apiResponse.reportFileDownloadUrl, + ) + : this.accessIntelligenceApiService.getReportFileData$( + organizationId, + apiResponse.id, + ); + } else { + reportData$ = of(apiResponse.reportData); + } + + return reportData$.pipe( + switchMap((reportData) => { + // Convert API → Data → Domain → View (following 4-layer architecture) + const data = new AccessReportData(); + data.id = apiResponse.id; + data.organizationId = apiResponse.organizationId; + data.reports = reportData; + data.summary = apiResponse.summaryData; + data.applications = apiResponse.applicationData; + data.creationDate = apiResponse.creationDate; + data.contentEncryptionKey = apiResponse.contentEncryptionKey; + + const domain = new AccessReport(data); + + // Domain handles its own decryption + return from( + domain.decrypt(this.riskInsightsEncryptionService, { organizationId, userId }), + ).pipe(map(({ view, hadLegacyBlobs }) => ({ report: view, hadLegacyBlobs }))); + }), + ); }), ); }), From c668f4577d2f52a9d55aed73816b062e5618aea2 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 1 Apr 2026 15:14:30 -0700 Subject: [PATCH 10/21] fix: update file-report-persistence to use AccessReportCreateApi from api-service branch Use AccessReportCreateApi (instead of AccessReportApi) for the createReport$ request, and cast metrics to Record to match the updateSummaryData$ signature from the dirt/api-service/pm-31942 abstraction. --- .../persistence/file-report-persistence.service.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts index 0ae20bf2b445..c9d1401210a2 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -24,8 +24,7 @@ import { AccessReportView, AccessReport, AccessReportData, - AccessReportApi, - AccessReportMetricsApi, + AccessReportCreateApi, } from "../../../models"; import { AccessIntelligenceApiService } from "../../abstractions/access-intelligence-api.service"; import { AccessReportEncryptionService } from "../../abstractions/access-report-encryption.service"; @@ -75,7 +74,7 @@ export class FileReportPersistenceService extends ReportPersistenceService { type: "application/json", }); - const request = new AccessReportApi(); + const request = new AccessReportCreateApi(); request.applicationData = data.applications; request.summaryData = data.summary; request.contentEncryptionKey = data.contentEncryptionKey; @@ -160,7 +159,7 @@ export class FileReportPersistenceService extends ReportPersistenceService { view.organizationId, view.id, data.summary, - metrics as AccessReportMetricsApi, + metrics as unknown as Record, ); return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe( From d6baa41efc7e77b31e13bac47522c9f87bc903bf Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 1 Apr 2026 15:21:12 -0700 Subject: [PATCH 11/21] properly access reportFileId --- .../persistence/file-report-persistence.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts index c9d1401210a2..435d3310225a 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -90,7 +90,7 @@ export class FileReportPersistenceService extends ReportPersistenceService { }), switchMap(({ result, reportFile, contentEncryptionKey }) => { const reportId = result.reportResponse.id as OrganizationReportId; - const reportFileId = result.reportResponse.reportFile ?? ""; + const reportFileId = result.reportResponse.reportFile?.id ?? ""; const upload$: Observable = result.fileUploadType === FileUploadType.Azure From 5df2be54270eba3e99e7dc6c2899f3a14ac9ca91 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 1 Apr 2026 17:47:47 -0700 Subject: [PATCH 12/21] refactor: unify file upload through FileUploadService for both Azure and Direct Replace the Azure/Direct branch with a single fileUploadService.upload call using result.fileUploadType. For Direct uploads, postDirect delegates to uploadReportFile$ on the API service. --- .../file-report-persistence.service.ts | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts index 435d3310225a..6f4d9b60f9da 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -15,7 +15,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { FileUploadService } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; -import { FileUploadType } from "@bitwarden/common/platform/enums"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { OrganizationReportId, OrganizationId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; @@ -92,30 +91,30 @@ export class FileReportPersistenceService extends ReportPersistenceService { const reportId = result.reportResponse.id as OrganizationReportId; const reportFileId = result.reportResponse.reportFile?.id ?? ""; - const upload$: Observable = - result.fileUploadType === FileUploadType.Azure - ? from(reportFile.arrayBuffer()).pipe( - switchMap((buffer) => - from( - this.fileUploadService.upload( - { url: result.reportFileUploadUrl, fileUploadType: FileUploadType.Azure }, - new EncString(""), - { buffer: new Uint8Array(buffer) } as unknown as EncArrayBuffer, - { - postDirect: () => Promise.resolve(), - renewFileUploadUrl: () => Promise.resolve(result.reportFileUploadUrl), - rollback: () => Promise.resolve(), - }, + const upload$ = from(reportFile.arrayBuffer()).pipe( + switchMap((buffer) => + from( + this.fileUploadService.upload( + { url: result.reportFileUploadUrl, fileUploadType: result.fileUploadType }, + new EncString(""), + { buffer: new Uint8Array(buffer) } as unknown as EncArrayBuffer, + { + postDirect: () => + firstValueFrom( + this.accessIntelligenceApiService.uploadReportFile$( + organizationId, + reportId, + reportFile, + reportFileId, + ), ), - ), - ), - ) - : this.accessIntelligenceApiService.uploadReportFile$( - organizationId, - reportId, - reportFile, - reportFileId, - ); + renewFileUploadUrl: () => Promise.resolve(result.reportFileUploadUrl), + rollback: () => Promise.resolve(), + }, + ), + ), + ), + ); return upload$.pipe(map(() => ({ id: reportId, contentEncryptionKey }))); }), From e2157effe6c50fcc337052cd047137ba05e166d4 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 15:37:33 -0700 Subject: [PATCH 13/21] add methods for new endpoints --- .../access-intelligence-api.service.ts | 11 +++++++- ...default-access-intelligence-api.service.ts | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index e7e2666c2fb6..63725b494974 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -53,4 +53,13 @@ export abstract class AccessIntelligenceApiService { reportId: string, applicationData: string, ): Observable; + + /** GET /reports/organizations/{orgId}/{reportId}/renew-upload */ + abstract renewReportFileUpload$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable; + + /** DELETE /reports/organizations/{orgId}/{reportId} */ + abstract deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable; } diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index 74fcca845d4f..c5374d09e412 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -2,7 +2,7 @@ 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 } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -103,6 +103,31 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe ); } + renewReportFileUpload$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable { + const response = this.apiService.send( + "GET", + `/reports/organizations/${orgId}/${reportId}/renew-upload`, + null, + true, + true, + ); + return from(response).pipe(map((res) => new AccessReportApi(res))); + } + + deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable { + const response = this.apiService.send( + "DELETE", + `/reports/organizations/${orgId}/${reportId}`, + null, + true, + false, + ); + return from(response); + } + uploadReportFile$( orgId: OrganizationId, reportId: string, From 6446c74f15c077f723c250e6e044230ec7f117ab Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 15:42:20 -0700 Subject: [PATCH 14/21] add tests, update reportId to use OrganizationReportId type --- .../access-intelligence-api.service.ts | 6 +- ...lt-access-intelligence-api.service.spec.ts | 61 ++++++++++++++++++- ...default-access-intelligence-api.service.ts | 6 +- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index 63725b494974..bc1588fc58c5 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -27,7 +27,7 @@ export abstract class AccessIntelligenceApiService { */ abstract uploadReportFile$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, file: File, reportFileId: string, ): Observable; @@ -42,7 +42,7 @@ export abstract class AccessIntelligenceApiService { /** PATCH /reports/organizations/{orgId}/data/summary/{reportId} */ abstract updateSummaryData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, summaryData: string, metrics?: Record, ): Observable; @@ -50,7 +50,7 @@ export abstract class AccessIntelligenceApiService { /** PATCH /reports/organizations/{orgId}/data/application/{reportId} */ abstract updateApplicationData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, applicationData: string, ): Observable; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts index 356e11c62a45..db2bec707123 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts @@ -4,7 +4,7 @@ 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 } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -20,7 +20,7 @@ describe("DefaultAccessIntelligenceApiService", () => { let mockApiService: MockProxy; const orgId = "org-123" as OrganizationId; - const reportId = "report-456"; + const reportId = "report-456" as OrganizationReportId; const reportFileId = "file-789"; beforeEach(() => { @@ -244,6 +244,63 @@ describe("DefaultAccessIntelligenceApiService", () => { }); }); + describe("renewReportFileUpload$", () => { + it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportApi", async () => { + const rawResponse = { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + reportFileUploadUrl: "https://storage.example.com/renewed-upload", + contentEncryptionKey: "enc-key", + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const result = await firstValueFrom(service.renewReportFileUpload$(orgId, reportId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId}/${reportId}/renew-upload`, + null, + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportApi); + expect(result.id).toBe(reportId); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Renew failed")); + + await expect(firstValueFrom(service.renewReportFileUpload$(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); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index c5374d09e412..e007e7ecfb99 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -44,7 +44,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateSummaryData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, summaryData: string, metrics?: Record, ): Observable { @@ -61,7 +61,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateApplicationData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, applicationData: string, ): Observable { const response = this.apiService.send( @@ -130,7 +130,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe uploadReportFile$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, file: File, reportFileId: string, ): Observable { From 2633f74d4ce1c5001d719518c8f5c518400d93f8 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 16:01:38 -0700 Subject: [PATCH 15/21] update return type for renew api --- .../access-intelligence-api.service.ts | 2 +- ...ult-access-intelligence-api.service.spec.ts | 18 +++++++++++------- .../default-access-intelligence-api.service.ts | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index bc1588fc58c5..0776bbacca04 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -58,7 +58,7 @@ export abstract class AccessIntelligenceApiService { abstract renewReportFileUpload$( orgId: OrganizationId, reportId: OrganizationReportId, - ): Observable; + ): Observable; /** DELETE /reports/organizations/{orgId}/{reportId} */ abstract deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts index db2bec707123..cb554a4d0134 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts @@ -245,13 +245,16 @@ describe("DefaultAccessIntelligenceApiService", () => { }); describe("renewReportFileUpload$", () => { - it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportApi", async () => { + it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportFileApi", async () => { const rawResponse = { - id: reportId, - organizationId: orgId, - creationDate: "2024-01-01T00:00:00Z", reportFileUploadUrl: "https://storage.example.com/renewed-upload", - contentEncryptionKey: "enc-key", + fileUploadType: FileUploadType.Azure, + reportResponse: { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + contentEncryptionKey: "enc-key", + }, }; mockApiService.send.mockResolvedValue(rawResponse); @@ -264,8 +267,9 @@ describe("DefaultAccessIntelligenceApiService", () => { true, true, ); - expect(result).toBeInstanceOf(AccessReportApi); - expect(result.id).toBe(reportId); + 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 () => { diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index e007e7ecfb99..4e89cff31bb7 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -106,7 +106,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe renewReportFileUpload$( orgId: OrganizationId, reportId: OrganizationReportId, - ): Observable { + ): Observable { const response = this.apiService.send( "GET", `/reports/organizations/${orgId}/${reportId}/renew-upload`, @@ -114,7 +114,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe true, true, ); - return from(response).pipe(map((res) => new AccessReportApi(res))); + return from(response).pipe(map((res) => new AccessReportFileApi(res))); } deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable { From 1d57af5cdfb85c9c2c487de6765f1b4568c1a0d1 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 16:04:46 -0700 Subject: [PATCH 16/21] update callbacks to use new endpoints --- .../persistence/file-report-persistence.service.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts index 6f4d9b60f9da..615bfd522682 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -108,8 +108,16 @@ export class FileReportPersistenceService extends ReportPersistenceService { reportFileId, ), ), - renewFileUploadUrl: () => Promise.resolve(result.reportFileUploadUrl), - rollback: () => Promise.resolve(), + renewFileUploadUrl: () => + firstValueFrom( + this.accessIntelligenceApiService + .renewReportFileUpload$(organizationId, reportId) + .pipe(map((res) => res.reportFileUploadUrl)), + ), + rollback: () => + firstValueFrom( + this.accessIntelligenceApiService.deleteReport$(organizationId, reportId), + ), }, ), ), From ce9a9ce20bdc72589cff42efe6d8dc3ca3c0f64d Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 17:31:34 -0700 Subject: [PATCH 17/21] add tests --- .../file-report-persistence.service.spec.ts | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts new file mode 100644 index 000000000000..b553861754db --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts @@ -0,0 +1,437 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of, throwError } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { + FileUploadApiMethods, + FileUploadService, +} from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; +import { FileUploadType } from "@bitwarden/common/platform/enums"; +import { makeEncString } from "@bitwarden/common/spec"; +import { OrganizationId, OrganizationReportId, UserId } from "@bitwarden/common/types/guid"; +import { LogService } from "@bitwarden/logging"; + +import { + AccessReport, + AccessReportApi, + AccessReportFileApi, + AccessReportView, +} from "../../../../access-intelligence/models"; +import { + createAccessReportMetrics, + createRiskInsights, + createRiskInsightsSummary, +} from "../../../../reports/risk-insights/testing/test-helpers"; +import { AccessIntelligenceApiService } from "../../abstractions/access-intelligence-api.service"; +import { AccessReportEncryptionService } from "../../abstractions/access-report-encryption.service"; + +import { FileReportPersistenceService } from "./file-report-persistence.service"; + +describe("FileReportPersistenceService", () => { + let service: FileReportPersistenceService; + let mockApiService: MockProxy; + let mockEncryptionService: MockProxy; + let mockAccountService: MockProxy; + let mockLogService: MockProxy; + let mockFileUploadService: MockProxy; + + const organizationId = "org-123" as OrganizationId; + const reportId = "report-456" as OrganizationReportId; + const reportFileId = "file-789"; + const userId = "user-789" as UserId; + + function makeCreateResponse( + uploadUrl = "https://storage.example.com/upload", + fileUploadType = FileUploadType.Azure, + ): AccessReportFileApi { + return new AccessReportFileApi({ + ReportFileUploadUrl: uploadUrl, + FileUploadType: fileUploadType, + ReportResponse: { + Id: reportId, + OrganizationId: organizationId, + CreationDate: "2024-01-01T00:00:00Z", + ContentEncryptionKey: "enc-key", + ReportFile: { Id: reportFileId }, + }, + }); + } + + function makeMockDomain(): AccessReport { + const domain = new AccessReport(); + domain.reports = makeEncString("encrypted-reports"); + domain.summary = makeEncString("encrypted-summary"); + domain.applications = makeEncString("encrypted-apps"); + domain.contentEncryptionKey = makeEncString("encryption-key"); + domain.creationDate = new Date(); + return domain; + } + + beforeAll(() => { + // jsdom does not implement File.prototype.arrayBuffer — polyfill for tests + if (!File.prototype.arrayBuffer) { + Object.defineProperty(File.prototype, "arrayBuffer", { + value: function () { + return Promise.resolve(new ArrayBuffer(0)); + }, + writable: true, + }); + } + }); + + beforeEach(() => { + mockApiService = mock(); + mockEncryptionService = mock(); + mockAccountService = mock(); + mockLogService = mock(); + mockFileUploadService = mock(); + + mockAccountService.activeAccount$ = of({ id: userId } as Account); + + service = new FileReportPersistenceService( + mockApiService, + mockEncryptionService, + mockAccountService, + mockLogService, + mockFileUploadService, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("saveReport$", () => { + it("should encrypt view, create report, upload file, and return report ID and key", async () => { + const view = createRiskInsights({ organizationId }); + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(makeMockDomain())); + mockApiService.createReport$.mockReturnValue(of(makeCreateResponse())); + mockFileUploadService.upload.mockResolvedValue(undefined); + + const result = await firstValueFrom(service.saveReport$(view, organizationId)); + + expect(result.id).toBe(reportId); + expect(result.contentEncryptionKey).toBeDefined(); + expect(AccessReport.fromView).toHaveBeenCalledWith(view, mockEncryptionService, { + organizationId, + userId, + }); + expect(mockApiService.createReport$).toHaveBeenCalledWith( + organizationId, + expect.objectContaining({ + contentEncryptionKey: expect.any(String), + fileSize: expect.any(Number), + }), + ); + }); + + it("should pass upload URL and type from createReport$ response to fileUploadService", async () => { + const view = createRiskInsights({ organizationId }); + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(makeMockDomain())); + mockApiService.createReport$.mockReturnValue( + of(makeCreateResponse("https://azure.blob/upload", FileUploadType.Azure)), + ); + mockFileUploadService.upload.mockResolvedValue(undefined); + + await firstValueFrom(service.saveReport$(view, organizationId)); + + expect(mockFileUploadService.upload).toHaveBeenCalledWith( + { url: "https://azure.blob/upload", fileUploadType: FileUploadType.Azure }, + expect.anything(), + expect.anything(), + expect.objectContaining({ + postDirect: expect.any(Function), + renewFileUploadUrl: expect.any(Function), + rollback: expect.any(Function), + }), + ); + }); + + it("should throw if contentEncryptionKey is absent from domain", async () => { + const view = createRiskInsights({ organizationId }); + const domainNoKey = new AccessReport(); + domainNoKey.contentEncryptionKey = undefined; + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(domainNoKey)); + + await expect(firstValueFrom(service.saveReport$(view, organizationId))).rejects.toThrow( + "Report encryption key not found", + ); + }); + + it("should throw if user ID is not found", async () => { + mockAccountService.activeAccount$ = of(null as any); + const view = createRiskInsights({ organizationId }); + + await expect(firstValueFrom(service.saveReport$(view, organizationId))).rejects.toThrow( + "Null or undefined account", + ); + }); + + it("should propagate createReport$ errors", async () => { + const view = createRiskInsights({ organizationId }); + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(makeMockDomain())); + mockApiService.createReport$.mockReturnValue(throwError(() => new Error("API error"))); + + await expect(firstValueFrom(service.saveReport$(view, organizationId))).rejects.toThrow( + "API error", + ); + }); + + it("should propagate file upload errors", async () => { + const view = createRiskInsights({ organizationId }); + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(makeMockDomain())); + mockApiService.createReport$.mockReturnValue(of(makeCreateResponse())); + mockFileUploadService.upload.mockRejectedValue(new Error("Upload failed")); + + await expect(firstValueFrom(service.saveReport$(view, organizationId))).rejects.toThrow( + "Upload failed", + ); + }); + + describe("file upload callbacks", () => { + let capturedMethods: FileUploadApiMethods; + + beforeEach(async () => { + const view = createRiskInsights({ organizationId }); + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(makeMockDomain())); + mockApiService.createReport$.mockReturnValue(of(makeCreateResponse())); + mockFileUploadService.upload.mockImplementation(async (_data, _name, _file, methods) => { + capturedMethods = methods; + }); + + await firstValueFrom(service.saveReport$(view, organizationId)); + }); + + it("postDirect callback should call uploadReportFile$ with correct args", async () => { + mockApiService.uploadReportFile$.mockReturnValue(of(undefined)); + + await capturedMethods.postDirect(new FormData()); + + expect(mockApiService.uploadReportFile$).toHaveBeenCalledWith( + organizationId, + reportId, + expect.any(File), + reportFileId, + ); + }); + + it("renewFileUploadUrl callback should call renewReportFileUpload$ and return the new URL", async () => { + const newUrl = "https://storage.example.com/renewed"; + const renewResponse = new AccessReportFileApi({ + ReportFileUploadUrl: newUrl, + FileUploadType: FileUploadType.Azure, + ReportResponse: { Id: reportId, OrganizationId: organizationId }, + }); + mockApiService.renewReportFileUpload$.mockReturnValue(of(renewResponse)); + + const url = await capturedMethods.renewFileUploadUrl(); + + expect(url).toBe(newUrl); + expect(mockApiService.renewReportFileUpload$).toHaveBeenCalledWith( + organizationId, + reportId, + ); + }); + + it("rollback callback should call deleteReport$", async () => { + mockApiService.deleteReport$.mockReturnValue(of(undefined)); + + await capturedMethods.rollback(); + + expect(mockApiService.deleteReport$).toHaveBeenCalledWith(organizationId, reportId); + }); + }); + }); + + describe("saveApplicationMetadata$", () => { + it("should call updateApplicationData$ and updateSummaryData$ with encrypted data", async () => { + const summary = createRiskInsightsSummary({ + totalApplicationCount: 5, + totalAtRiskApplicationCount: 2, + totalMemberCount: 10, + totalAtRiskMemberCount: 3, + }); + const view = createRiskInsights({ id: reportId, organizationId, summary }); + + const mockMetrics = createAccessReportMetrics({ + totalApplicationCount: 5, + totalAtRiskApplicationCount: 2, + totalMemberCount: 10, + totalAtRiskMemberCount: 3, + }); + jest.spyOn(view, "toMetrics").mockReturnValue(mockMetrics); + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(makeMockDomain())); + + mockApiService.updateApplicationData$.mockReturnValue(of({} as AccessReportApi)); + mockApiService.updateSummaryData$.mockReturnValue(of({} as AccessReportApi)); + + await firstValueFrom(service.saveApplicationMetadata$(view)); + + expect(mockApiService.updateApplicationData$).toHaveBeenCalledWith( + organizationId, + reportId, + expect.any(String), + ); + expect(mockApiService.updateSummaryData$).toHaveBeenCalledWith( + organizationId, + reportId, + expect.any(String), + expect.any(Object), + ); + }); + + it("should throw if user ID is not found", async () => { + mockAccountService.activeAccount$ = of(null as any); + const view = createRiskInsights({ id: reportId, organizationId }); + + await expect(firstValueFrom(service.saveApplicationMetadata$(view))).rejects.toThrow( + "Null or undefined account", + ); + }); + + it("should propagate encryption errors", async () => { + const view = createRiskInsights({ id: reportId, organizationId }); + jest + .spyOn(AccessReport, "fromView") + .mockReturnValue(throwError(() => new Error("Encryption failed"))); + + await expect(firstValueFrom(service.saveApplicationMetadata$(view))).rejects.toThrow( + "Encryption failed", + ); + }); + + it("should propagate API update errors", async () => { + const view = createRiskInsights({ id: reportId, organizationId }); + jest.spyOn(AccessReport, "fromView").mockReturnValue(of(makeMockDomain())); + jest.spyOn(view, "toMetrics").mockReturnValue(createAccessReportMetrics({})); + + mockApiService.updateApplicationData$.mockReturnValue( + throwError(() => new Error("Update failed")), + ); + + await expect(firstValueFrom(service.saveApplicationMetadata$(view))).rejects.toThrow( + "Update failed", + ); + }); + }); + + describe("loadReport$", () => { + function makeApiResponse(overrides: Partial = {}): AccessReportApi { + const response = new AccessReportApi(); + response.id = reportId; + response.organizationId = organizationId; + response.contentEncryptionKey = "enc-key"; + response.summaryData = "encrypted-summary"; + response.applicationData = "encrypted-apps"; + response.creationDate = "2024-01-01T00:00:00Z"; + Object.assign(response, overrides); + return response; + } + + function mockDecrypt(view?: AccessReportView) { + const decryptedView = view ?? createRiskInsights({ id: reportId, organizationId }); + jest + .spyOn(AccessReport.prototype, "decrypt") + .mockReturnValue(of({ view: decryptedView, hadLegacyBlobs: false })); + return decryptedView; + } + + it("should return null if the API returns 404", async () => { + mockApiService.getLatestReport$.mockReturnValue( + throwError(() => new ErrorResponse({ Message: "Not found" }, 404)), + ); + + const result = await firstValueFrom(service.loadReport$(organizationId)); + + expect(result).toBeNull(); + }); + + it("should propagate non-404 API errors", async () => { + mockApiService.getLatestReport$.mockReturnValue( + throwError(() => new ErrorResponse({ Message: "Server error" }, 500)), + ); + + await expect(firstValueFrom(service.loadReport$(organizationId))).rejects.toBeInstanceOf( + ErrorResponse, + ); + }); + + it("should throw if contentEncryptionKey is missing", async () => { + mockApiService.getLatestReport$.mockReturnValue( + of(makeApiResponse({ contentEncryptionKey: "" })), + ); + + await expect(firstValueFrom(service.loadReport$(organizationId))).rejects.toThrow( + "Report encryption key not found", + ); + }); + + it("should use inline reportData when reportFileDownloadUrl is absent (V1 fallback)", async () => { + const apiResponse = makeApiResponse({ reportData: "inline-report-data" }); + mockApiService.getLatestReport$.mockReturnValue(of(apiResponse)); + mockDecrypt(); + + await firstValueFrom(service.loadReport$(organizationId)); + + expect(mockApiService.downloadReportFile$).not.toHaveBeenCalled(); + expect(mockApiService.getReportFileData$).not.toHaveBeenCalled(); + }); + + it("should download file from Azure blob URL when reportFileDownloadUrl is an Azure URL", async () => { + const azureUrl = "https://myaccount.blob.core.windows.net/container/report.json?sas=token"; + const apiResponse = makeApiResponse({ reportFileDownloadUrl: azureUrl }); + mockApiService.getLatestReport$.mockReturnValue(of(apiResponse)); + mockApiService.downloadReportFile$.mockReturnValue(of("file-content")); + mockDecrypt(); + + await firstValueFrom(service.loadReport$(organizationId)); + + expect(mockApiService.downloadReportFile$).toHaveBeenCalledWith(azureUrl); + expect(mockApiService.getReportFileData$).not.toHaveBeenCalled(); + }); + + it("should fetch file via authenticated API when reportFileDownloadUrl is a non-Azure URL", async () => { + const serverUrl = "https://my-selfhosted-server.com/reports/download/file-id"; + const apiResponse = makeApiResponse({ reportFileDownloadUrl: serverUrl }); + mockApiService.getLatestReport$.mockReturnValue(of(apiResponse)); + mockApiService.getReportFileData$.mockReturnValue(of("file-content")); + mockDecrypt(); + + await firstValueFrom(service.loadReport$(organizationId)); + + expect(mockApiService.getReportFileData$).toHaveBeenCalledWith(organizationId, reportId); + expect(mockApiService.downloadReportFile$).not.toHaveBeenCalled(); + }); + + it("should decrypt and return the report view", async () => { + mockApiService.getLatestReport$.mockReturnValue(of(makeApiResponse())); + const expectedView = mockDecrypt(); + + const result = await firstValueFrom(service.loadReport$(organizationId)); + + expect(result).not.toBeNull(); + expect(result!.report).toBe(expectedView); + expect(result!.hadLegacyBlobs).toBe(false); + }); + + it("should propagate decryption errors", async () => { + mockApiService.getLatestReport$.mockReturnValue(of(makeApiResponse())); + jest + .spyOn(AccessReport.prototype, "decrypt") + .mockReturnValue(throwError(() => new Error("Decryption failed"))); + + await expect(firstValueFrom(service.loadReport$(organizationId))).rejects.toThrow( + "Decryption failed", + ); + }); + + it("should throw if user ID is not found", async () => { + mockAccountService.activeAccount$ = of(null as any); + + await expect(firstValueFrom(service.loadReport$(organizationId))).rejects.toThrow( + "Null or undefined account", + ); + }); + }); +}); From 5a638c6147a644fe5587a3d5129b8177d5d2d8ef Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 3 Apr 2026 11:00:20 -0700 Subject: [PATCH 18/21] update renew endpoint to correct path --- .../abstractions/access-intelligence-api.service.ts | 4 ++-- .../default-access-intelligence-api.service.spec.ts | 10 +++++----- .../api/default-access-intelligence-api.service.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index 0776bbacca04..b881e9d1f1d9 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -54,8 +54,8 @@ export abstract class AccessIntelligenceApiService { applicationData: string, ): Observable; - /** GET /reports/organizations/{orgId}/{reportId}/renew-upload */ - abstract renewReportFileUpload$( + /** GET /reports/organizations/{orgId}/{reportId}/file/renew */ + abstract renewReportFileUploadLink$( orgId: OrganizationId, reportId: OrganizationReportId, ): Observable; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts index cb554a4d0134..7b676f112410 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts @@ -258,11 +258,11 @@ describe("DefaultAccessIntelligenceApiService", () => { }; mockApiService.send.mockResolvedValue(rawResponse); - const result = await firstValueFrom(service.renewReportFileUpload$(orgId, reportId)); + const result = await firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId)); expect(mockApiService.send).toHaveBeenCalledWith( "GET", - `/reports/organizations/${orgId}/${reportId}/renew-upload`, + `/reports/organizations/${orgId}/${reportId}/file/renew`, null, true, true, @@ -275,9 +275,9 @@ describe("DefaultAccessIntelligenceApiService", () => { it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("Renew failed")); - await expect(firstValueFrom(service.renewReportFileUpload$(orgId, reportId))).rejects.toThrow( - "Renew failed", - ); + await expect( + firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId)), + ).rejects.toThrow("Renew failed"); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index 4e89cff31bb7..036e5a0fa5bc 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -103,13 +103,13 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe ); } - renewReportFileUpload$( + renewReportFileUploadLink$( orgId: OrganizationId, reportId: OrganizationReportId, ): Observable { const response = this.apiService.send( "GET", - `/reports/organizations/${orgId}/${reportId}/renew-upload`, + `/reports/organizations/${orgId}/${reportId}/file/renew`, null, true, true, From 6cd364ad799e5b32b368319c07b638f4cffa47e3 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 3 Apr 2026 11:10:51 -0700 Subject: [PATCH 19/21] update method --- .../persistence/file-report-persistence.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts index 615bfd522682..915e46a05af7 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -111,7 +111,7 @@ export class FileReportPersistenceService extends ReportPersistenceService { renewFileUploadUrl: () => firstValueFrom( this.accessIntelligenceApiService - .renewReportFileUpload$(organizationId, reportId) + .renewReportFileUploadLink$(organizationId, reportId) .pipe(map((res) => res.reportFileUploadUrl)), ), rollback: () => From 180d3b4a9705b221f82ed3a246052927b62355b3 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 3 Apr 2026 11:11:25 -0700 Subject: [PATCH 20/21] update test --- .../persistence/file-report-persistence.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts index b553861754db..b03d6f9f5917 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.spec.ts @@ -223,12 +223,12 @@ describe("FileReportPersistenceService", () => { FileUploadType: FileUploadType.Azure, ReportResponse: { Id: reportId, OrganizationId: organizationId }, }); - mockApiService.renewReportFileUpload$.mockReturnValue(of(renewResponse)); + mockApiService.renewReportFileUploadLink$.mockReturnValue(of(renewResponse)); const url = await capturedMethods.renewFileUploadUrl(); expect(url).toBe(newUrl); - expect(mockApiService.renewReportFileUpload$).toHaveBeenCalledWith( + expect(mockApiService.renewReportFileUploadLink$).toHaveBeenCalledWith( organizationId, reportId, ); From 8ce3c07b928e25897bde6d6466cf778b30f65a37 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:40:28 -0700 Subject: [PATCH 21/21] Delete bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts --- .../models/api/access-report-create.api.ts | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts deleted file mode 100644 index 68d53fd54e05..000000000000 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AccessReportMetricsApi } from "./access-report-metrics.api"; - -/** - * Request body for POST /reports/organizations/{organizationId} - * - */ -export class AccessReportCreateApi { - contentEncryptionKey?: string; - summaryData?: string; - applicationData?: string; - metrics?: AccessReportMetricsApi; - fileSize?: number; -}