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/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts index b881e9d1f1d9..a36aa58c093a 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 036e5a0fa5bc..2b090b722ffa 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 @@ -147,4 +147,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.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..b03d6f9f5917 --- /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.renewReportFileUploadLink$.mockReturnValue(of(renewResponse)); + + const url = await capturedMethods.renewFileUploadUrl(); + + expect(url).toBe(newUrl); + expect(mockApiService.renewReportFileUploadLink$).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", + ); + }); + }); +}); 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..915e46a05af7 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/persistence/file-report-persistence.service.ts @@ -0,0 +1,254 @@ +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 { 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, + AccessReportCreateApi, +} 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 AccessReportCreateApi(); + 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?.id ?? ""; + + 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, + ), + ), + renewFileUploadUrl: () => + firstValueFrom( + this.accessIntelligenceApiService + .renewReportFileUploadLink$(organizationId, reportId) + .pipe(map((res) => res.reportFileUploadUrl)), + ), + rollback: () => + firstValueFrom( + this.accessIntelligenceApiService.deleteReport$(organizationId, reportId), + ), + }, + ), + ), + ), + ); + + 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 unknown as Record, + ); + + return forkJoin([updateApplicationsCall, updateSummaryCall]).pipe( + map(() => undefined as void), + ); + }), + ); + }), + ); + } + + 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.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 === "") { + throw new Error("Report encryption key not found"); + } + + // 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 }))); + }), + ); + }), + ); + }), + ); + } +}