From 0715619435ef01c201f4f3458eb0d8e4448cbdc3 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:14:12 +0200 Subject: [PATCH] feat(ui): import Prowler OCSF findings from the Scans page (#12554) Co-authored-by: alejandrobailo --- .../msw/handlers/ingestions.fixtures.ts | 74 ++++ ui/__tests__/msw/handlers/ingestions.ts | 100 +++++ ui/actions/auth/auth.test.ts | 23 ++ ui/actions/auth/auth.ts | 1 + ui/app/(prowler)/scans/page.tsx | 4 + ui/app/(prowler)/scans/scans-page.harness.tsx | 323 ++++++++++++++++ .../scans/scans-page.integration.test.tsx | 326 +++++++++++++++++ .../ingestions/[ingestionId]/route.test.ts | 231 ++++++++++++ ui/app/api/ingestions/[ingestionId]/route.ts | 59 +++ ui/app/api/ingestions/route.test.ts | 345 ++++++++++++++++++ ui/app/api/ingestions/route.ts | 114 ++++++ ui/auth.config.test.ts | 3 + ui/auth.config.ts | 3 +- .../import-findings-drag-drop.added.md | 1 + ui/components/scans/import-findings-modal.tsx | 316 ++++++++++++++++ ui/components/scans/scans-page-shell.test.tsx | 84 ++++- ui/components/scans/scans-page-shell.tsx | 6 + ui/components/scans/use-ingestion-polling.ts | 157 ++++++++ .../file-upload/file-upload-dropzone.tsx | 48 ++- ui/hooks/use-auth.ts | 1 + ui/lib/ingestions.ts | 55 +++ ui/types/index.ts | 1 + ui/types/ingestions.ts | 21 ++ ui/types/users.ts | 72 ++-- 24 files changed, 2314 insertions(+), 54 deletions(-) create mode 100644 ui/__tests__/msw/handlers/ingestions.fixtures.ts create mode 100644 ui/__tests__/msw/handlers/ingestions.ts create mode 100644 ui/app/(prowler)/scans/scans-page.harness.tsx create mode 100644 ui/app/(prowler)/scans/scans-page.integration.test.tsx create mode 100644 ui/app/api/ingestions/[ingestionId]/route.test.ts create mode 100644 ui/app/api/ingestions/[ingestionId]/route.ts create mode 100644 ui/app/api/ingestions/route.test.ts create mode 100644 ui/app/api/ingestions/route.ts create mode 100644 ui/changelog.d/import-findings-drag-drop.added.md create mode 100644 ui/components/scans/import-findings-modal.tsx create mode 100644 ui/components/scans/use-ingestion-polling.ts create mode 100644 ui/lib/ingestions.ts create mode 100644 ui/types/ingestions.ts diff --git a/ui/__tests__/msw/handlers/ingestions.fixtures.ts b/ui/__tests__/msw/handlers/ingestions.fixtures.ts new file mode 100644 index 0000000000..25d7b16719 --- /dev/null +++ b/ui/__tests__/msw/handlers/ingestions.fixtures.ts @@ -0,0 +1,74 @@ +export const INGESTION_ID = "ingestion-123"; + +export interface IngestionFixture { + id: string; + totalRecords: number; + processedRecords: number; + invalidRecords: number; +} + +export const INGESTION_REJECTION = { + INVALID_REPORT: "invalid-report", + SUBSCRIPTION_REQUIRED: "subscription-required", + PERMISSION_DENIED: "permission-denied", + FILE_TOO_LARGE: "file-too-large", + RATE_LIMITED: "rate-limited", + UNEXPECTED: "unexpected", +} as const; + +export type IngestionRejection = + (typeof INGESTION_REJECTION)[keyof typeof INGESTION_REJECTION]; + +export interface IngestionRejectionFixture { + status: number; + message: string; +} + +export const ingestionRejectionFixture = ( + rejection: IngestionRejection, +): IngestionRejectionFixture => { + const fixtures = { + [INGESTION_REJECTION.INVALID_REPORT]: { + status: 400, + message: "The report is not a valid Prowler OCSF finding report.", + }, + [INGESTION_REJECTION.SUBSCRIPTION_REQUIRED]: { + status: 402, + message: "A Prowler Cloud subscription is required to import findings.", + }, + [INGESTION_REJECTION.PERMISSION_DENIED]: { + status: 403, + message: "You do not have permission to import findings.", + }, + [INGESTION_REJECTION.FILE_TOO_LARGE]: { + status: 413, + message: "The selected file exceeds the allowed upload size.", + }, + [INGESTION_REJECTION.RATE_LIMITED]: { + status: 429, + message: "Too many import requests. Please try again shortly.", + }, + [INGESTION_REJECTION.UNEXPECTED]: { + status: 500, + message: "Unable to start the import. Please try again.", + }, + } as const; + + return fixtures[rejection]; +}; + +export const ingestionFixture = (): IngestionFixture => ({ + id: INGESTION_ID, + totalRecords: 3, + processedRecords: 3, + invalidRecords: 1, +}); + +// Stopped partway: every record read is accounted for, so the unprocessed ones +// are reported as invalid. +export const partiallyProcessedIngestionFixture = (): IngestionFixture => ({ + id: INGESTION_ID, + totalRecords: 5, + processedRecords: 3, + invalidRecords: 2, +}); diff --git a/ui/__tests__/msw/handlers/ingestions.ts b/ui/__tests__/msw/handlers/ingestions.ts new file mode 100644 index 0000000000..45a61360e6 --- /dev/null +++ b/ui/__tests__/msw/handlers/ingestions.ts @@ -0,0 +1,100 @@ +import { delay, http, HttpResponse } from "msw"; + +import type { + IngestionFixture, + IngestionRejectionFixture, +} from "./ingestions.fixtures"; + +const API = "/api/ingestions"; + +// Counters stay zero until the job reaches a terminal status; `failed` still +// reports progress, which is what tells a partial import from one that landed nothing. +const ingestionResponse = ( + fixture: IngestionFixture, + status: "pending" | "processing" | "completed" | "failed", +) => { + const terminal = status === "completed" || status === "failed"; + return { + data: { + id: fixture.id, + status, + totalRecords: fixture.totalRecords, + processedRecords: terminal ? fixture.processedRecords : 0, + invalidRecords: terminal ? fixture.invalidRecords : 0, + }, + }; +}; + +interface IngestionHandlerOptions { + uploadRejection?: IngestionRejectionFixture; + uploadDelayMs?: number; + statusErrorAt?: number; + statusDelayMs?: number; + statusResponseGate?: Promise; + statusSequence?: Array<"processing" | "completed" | "failed">; + onStatusRequest?: (inFlight: number) => void; +} + +export const handlersForIngestion = ( + fixture: IngestionFixture, + { + uploadRejection, + uploadDelayMs, + statusErrorAt, + statusDelayMs, + statusResponseGate, + statusSequence, + onStatusRequest, + }: IngestionHandlerOptions = {}, +) => { + let statusRequestCount = 0; + let inFlightStatusRequests = 0; + + return [ + http.post(API, async ({ request }) => { + const formData = await request.formData(); + if (uploadDelayMs) await delay(uploadDelayMs); + if (!(formData.get("file") instanceof File)) { + return HttpResponse.json( + { error: "A file is required." }, + { status: 400 }, + ); + } + + if (uploadRejection) { + return HttpResponse.json( + { error: uploadRejection.message }, + { status: uploadRejection.status }, + ); + } + + return HttpResponse.json(ingestionResponse(fixture, "pending"), { + status: 202, + }); + }), + http.get(`${API}/:id`, async ({ params }) => { + if (params.id !== fixture.id) { + return HttpResponse.json({ error: "Not found." }, { status: 404 }); + } + + statusRequestCount += 1; + inFlightStatusRequests += 1; + onStatusRequest?.(inFlightStatusRequests); + if (statusDelayMs) await delay(statusDelayMs); + if (statusResponseGate) await statusResponseGate; + inFlightStatusRequests -= 1; + onStatusRequest?.(inFlightStatusRequests); + if (statusRequestCount === statusErrorAt) { + return HttpResponse.json( + { error: "Unable to retrieve the import status. Please try again." }, + { status: 503 }, + ); + } + + const status = + statusSequence?.[statusRequestCount - 1] ?? + (statusRequestCount === 1 ? "processing" : "completed"); + return HttpResponse.json(ingestionResponse(fixture, status)); + }), + ]; +}; diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts index 016747b065..1f20392bb8 100644 --- a/ui/actions/auth/auth.test.ts +++ b/ui/actions/auth/auth.test.ts @@ -132,6 +132,29 @@ describe("auth actions", () => { expect(requestUrl.searchParams.get("utm_source")).toBe("blackhat"); }); + it("should carry manage_ingestions into the session permissions", async () => { + // Given + mockUserMe({ manage_ingestions: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_ingestions).toBe(true); + }); + + it("should default manage_ingestions to false when the role omits it", async () => { + // Given + mockUserMe({ manage_scans: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_ingestions).toBe(false); + expect(result.permissions.manage_scans).toBe(true); + }); + it("should carry manage_lighthouse_ai_configuration into the session permissions", async () => { // Given mockUserMe({ manage_lighthouse_ai_configuration: true }); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 0df5fe81c9..6d275bbf92 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -180,6 +180,7 @@ export const getUserByMe = async ( manage_account: userRole.attributes.manage_account || false, manage_providers: userRole.attributes.manage_providers || false, manage_scans: userRole.attributes.manage_scans || false, + manage_ingestions: userRole.attributes.manage_ingestions || false, manage_integrations: userRole.attributes.manage_integrations || false, manage_billing: userRole.attributes.manage_billing || false, manage_alerts: userRole.attributes.manage_alerts || false, diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index a8330f64aa..4b308921e4 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -193,6 +193,9 @@ export default async function Scans({ const hasManageScansPermission = Boolean( session?.user?.permissions?.manage_scans, ); + const hasManageIngestionsPermission = Boolean( + session?.user?.permissions?.manage_ingestions, + ); const activeScanCount = await getActiveScanCount(resolvedSearchParams); // Mirrors ScansPageShell's launch gate: it only mounts the view-first-scan trigger // when Launch Scan is usable (manage_scans + a connected provider). Without the @@ -218,6 +221,7 @@ export default async function Scans({ providers={providers} providerGroups={providerGroups} hasManageScansPermission={hasManageScansPermission} + hasManageIngestionsPermission={hasManageIngestionsPermission} activeScanCount={activeScanCount} > ; +} + +export class ScansPageHarness extends BrowserHarness { + private maxInFlightStatusRequests = 0; + private mounted: ReturnType | null = null; + private releaseHeldStatusResponse: (() => void) | null = null; + private statusDelayMs = 0; + + async mount({ + hasManageIngestionsPermission = true, + hasManageScansPermission = false, + holdStatusResponse = false, + uploadRejection, + uploadDelayMs, + statusErrorAt, + statusDelayMs, + statusSequence, + }: MountOptions = {}): Promise { + const statusResponseGate = holdStatusResponse + ? new Promise((resolve) => { + this.releaseHeldStatusResponse = resolve; + }) + : undefined; + + worker.use( + ...handlersForIngestion(this.fixture, { + uploadRejection, + uploadDelayMs, + statusErrorAt, + statusDelayMs, + statusResponseGate, + statusSequence, + onStatusRequest: (inFlight) => { + this.maxInFlightStatusRequests = Math.max( + this.maxInFlightStatusRequests, + inFlight, + ); + }, + }), + ); + this.trackRequests(worker); + this.statusDelayMs = statusDelayMs ?? 0; + + this.mounted = render( + + + , + ); + } + + async leaveScansPage(): Promise { + const mounted = await this.mounted; + if (!mounted) throw new Error("leaveScansPage: the page is not mounted"); + mounted.unmount(); + this.mounted = null; + } + + hasImportFindingsAction(): boolean { + return this.buttonByText(/Import Findings/) !== null; + } + + async openImportFindings(): Promise { + await this.clickButton(/Import Findings/); + await this.waitForText(/Import findings/i); + } + + async selectFile(file: File): Promise { + const input = await this.waitFor(() => + this.container.querySelector('input[type="file"]'), + ); + await this.user.upload(input, file); + } + + async dropFile(file: File): Promise { + await this.attemptDrop(file); + await this.waitFor(() => + this.q('[data-testid="import-findings-dropzone"]')?.textContent?.includes( + file.name, + ), + ); + } + + /** Drop without waiting for the file to be taken: dropFile hangs on a refused drop. */ + async attemptDrop(file: File): Promise { + const dropzone = await this.waitFor(() => + this.q('[data-testid="import-findings-dropzone"] [role="button"]'), + ); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + dropzone.dispatchEvent( + new DragEvent("drop", { bubbles: true, dataTransfer }), + ); + } + + /** Both halves matter: drop and keyboard gate on the zone, the file picker on the input. */ + isDropzoneFrozen(): boolean { + const zone = this.q( + '[data-testid="import-findings-dropzone"] [role="button"]', + ); + const input = this.container.querySelector( + '[data-testid="import-findings-dropzone"] input[type="file"]', + ); + return ( + zone?.getAttribute("aria-disabled") === "true" && input?.disabled === true + ); + } + + async activateDropzoneWithKeyboard(): Promise { + const dropzone = await this.waitFor(() => + this.q('[data-testid="import-findings-dropzone"] [role="button"]'), + ); + const input = await this.waitFor(() => + this.container.querySelector('input[type="file"]'), + ); + let opened = false; + input.addEventListener("click", () => { + opened = true; + }); + + dropzone.focus(); + await this.user.keyboard("[Enter]"); + return opened; + } + + isImportEnabled(): boolean { + return !this.buttonByText(/Start import/i)?.disabled; + } + + async waitForValidationMessage(message: RegExp): Promise { + await this.waitForText(message); + } + + async waitForUploadError(message: RegExp): Promise { + await this.waitForText(message); + } + + async waitForUploadInProgress(): Promise { + await this.waitFor( + () => this.buttonByText(/Importing/i), + 5000, + "the upload to report progress", + ); + } + + isUploadInProgress(): boolean { + const submit = this.buttonByText(/Importing/i); + return submit !== null && submit.disabled; + } + + async closeImportFindings(): Promise { + await this.user.keyboard("[Escape]"); + await this.waitFor(() => + this.q('[role="dialog"]') === null ? true : null, + ); + } + + async closeImmediatelyBeforeStatusCompletes(): Promise { + const closeButton = await this.waitForButton(/^Close$/i); + const releaseStatusResponse = this.releaseHeldStatusResponse; + if (!releaseStatusResponse) { + throw new Error( + "closeImmediatelyBeforeStatusCompletes: no status response is held", + ); + } + + closeButton.click(); + releaseStatusResponse(); + this.releaseHeldStatusResponse = null; + await this.waitFor(() => + this.q('[role="dialog"]') === null ? true : null, + ); + } + + selectedFileName(): string | null { + const dropzone = this.q('[data-testid="import-findings-dropzone"]'); + return dropzone?.textContent?.match(/[^\s]+\.json/i)?.[0] ?? null; + } + + async submitImport(): Promise { + await this.clickButton(/Start import/i); + } + + async retryUpload(): Promise { + await this.clickButton(/Retry import/i); + await this.waitFor(() => (this.ingestionPostCount === 2 ? true : null)); + } + + /** Anchored on "Import completed": the failed summary reports the same counters. */ + async waitForCompletedSummary(): Promise { + const { processedRecords, totalRecords, invalidRecords } = this.fixture; + await this.waitForText( + new RegExp( + `Import completed: ${totalRecords} total records, ${processedRecords} processed, ${invalidRecords} invalid`, + "i", + ), + 15000, + ); + } + + hasCompletionNotification(): boolean { + return this.containsText(/Findings import completed/i); + } + + hasCompletedSummary(): boolean { + return this.containsText(/Import completed:/i); + } + + hasStopTrackingAction(): boolean { + return this.buttonByText(/Stop tracking/i) !== null; + } + + async waitForCompletionNotification(): Promise { + await this.waitFor( + () => this.hasCompletionNotification(), + 15000, + "the import completion notification", + ); + } + + async waitForTrackingStatus(): Promise { + await this.waitForText(/Import is processing/i); + } + + async waitForStatusError(): Promise { + await this.waitForText(/Unable to retrieve the import status/i, 15000); + } + + async waitForFailedImport(): Promise { + await this.waitForText(/Import failed/i, 15000); + } + + async waitForFailedImportSummary(): Promise { + const { processedRecords, totalRecords, invalidRecords } = this.fixture; + await this.waitForText( + new RegExp( + `${processedRecords} of ${totalRecords} records processed, ${invalidRecords} invalid`, + "i", + ), + 15000, + ); + } + + async retryStatus(): Promise { + await this.clickButton(/Retry status/i); + await this.waitFor(() => + this.ingestionStatusPollCount >= 2 ? true : null, + ); + } + + async waitForFirstStatusPoll(): Promise { + await this.waitFor( + () => this.ingestionStatusPollCount >= 1, + 5000, + "the first status poll", + ); + } + + /** Outlast an uncancelled poll: its response lands, then the modal's 5s interval passes and the next would fire. */ + async waitPastTheNextPoll(): Promise { + await this.waitForTransition(this.statusDelayMs + 5700); + } + + get ingestionPostCount(): number { + return this.countRequests("POST", "/api/ingestions"); + } + + get ingestionStatusPollCount(): number { + return this.countRequests("GET", "/api/ingestions/"); + } + + get maximumInFlightStatusRequests(): number { + return this.maxInFlightStatusRequests; + } + + /** A page refresh is the only thing that brings an imported scan into the table. */ + get pageRefreshCount(): number { + return vi.mocked(readMockedRouter().refresh).mock.calls.length; + } + + async uploadedFileName(): Promise { + const entry = [...this.requestLog] + .reverse() + .find( + (request) => + request.method === "POST" && + new URL(request.url).pathname === "/api/ingestions", + ); + if (!entry) return null; + + const file = (await entry.request.formData()).get("file"); + return file instanceof File ? file.name : null; + } +} diff --git a/ui/app/(prowler)/scans/scans-page.integration.test.tsx b/ui/app/(prowler)/scans/scans-page.integration.test.tsx new file mode 100644 index 0000000000..d150a2d0f8 --- /dev/null +++ b/ui/app/(prowler)/scans/scans-page.integration.test.tsx @@ -0,0 +1,326 @@ +import { describe, expect } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { + INGESTION_REJECTION, + ingestionFixture, + ingestionRejectionFixture, + partiallyProcessedIngestionFixture, +} from "@/__tests__/msw/handlers/ingestions.fixtures"; + +import { ScansPageHarness } from "./scans-page.harness"; + +describe("Scans page import findings", () => { + it("imports one valid finding file without scan permission or providers", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + + expect(harness.hasImportFindingsAction()).toBe(true); + await harness.openImportFindings(); + await harness.selectFile( + new File(['[{"type":"finding"}]'], "findings.ocsf.json", { + type: "application/json", + }), + ); + await harness.submitImport(); + + await harness.waitForCompletedSummary(); + // The summary is on screen, so the toast would have been raised in the same + // render: its absence is the open dialog suppressing it. + expect(harness.hasCompletionNotification()).toBe(false); + expect(harness.pageRefreshCount).toBe(1); + expect(harness.ingestionPostCount).toBe(1); + expect(await harness.uploadedFileName()).toBe("findings.ocsf.json"); + expect(harness.ingestionStatusPollCount).toBeGreaterThanOrEqual(2); + }); + + it("hides Import Findings in Local Server", async ({ seedRuntimeConfig }) => { + seedRuntimeConfig({ cloudEnabled: false }); + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + + expect(harness.hasImportFindingsAction()).toBe(false); + }); + + it("hides Import Findings without Manage Ingestions", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ hasManageIngestionsPermission: false }); + + expect(harness.hasImportFindingsAction()).toBe(false); + }); + + it("supports keyboard activation and drag-and-drop selection", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + await harness.openImportFindings(); + + await expect(harness.activateDropzoneWithKeyboard()).resolves.toBe(true); + await harness.dropFile( + new File(["[]"], "dropped.OCSF.JSON", { type: "application/json" }), + ); + + expect(harness.selectedFileName()).toBe("dropped.OCSF.JSON"); + expect(harness.isImportEnabled()).toBe(true); + }); + + it("replaces a selected file and rejects unsupported and empty selections", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "first.ocsf.json")); + await harness.selectFile(new File(["[]"], "replacement.ocsf.json")); + + expect(harness.selectedFileName()).toBe("replacement.ocsf.json"); + await harness.selectFile(new File(["[]"], "unsupported.json")); + await harness.waitForValidationMessage(/\.ocsf\.json/i); + expect(harness.ingestionPostCount).toBe(0); + + await harness.selectFile(new File([], "empty.ocsf.json")); + await harness.waitForValidationMessage(/empty/i); + expect(harness.ingestionPostCount).toBe(0); + }); + + it("clears an unsubmitted selection after closing the dialog", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.closeImportFindings(); + await harness.openImportFindings(); + + expect(harness.selectedFileName()).toBeNull(); + expect(harness.isImportEnabled()).toBe(false); + }); + + for (const rejection of Object.values(INGESTION_REJECTION)) { + it(`keeps the file recoverable after a ${rejection} upload rejection`, async () => { + const rejectionFixture = ingestionRejectionFixture(rejection); + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ uploadRejection: rejectionFixture }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + + await harness.waitForUploadError( + new RegExp(rejectionFixture.message, "i"), + ); + expect(harness.selectedFileName()).toBe("findings.ocsf.json"); + expect(harness.ingestionPostCount).toBe(1); + + await harness.retryUpload(); + expect(harness.ingestionPostCount).toBe(2); + }); + } + + it("continues tracking an accepted import while the dialog is closed", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["processing", "completed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForTrackingStatus(); + await harness.closeImportFindings(); + + await harness.openImportFindings(); + await harness.waitForCompletedSummary(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("keeps an in-flight submission after closing and reopening the dialog", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + uploadDelayMs: 3000, + statusSequence: ["processing", "completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForUploadInProgress(); + await harness.closeImportFindings(); + await harness.openImportFindings(); + + expect(harness.isUploadInProgress()).toBe(true); + expect(harness.selectedFileName()).toBe("findings.ocsf.json"); + + await harness.waitForTrackingStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("refuses to swap the file while an import is in flight", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + uploadDelayMs: 3000, + statusSequence: ["processing", "completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForUploadInProgress(); + + expect(harness.isDropzoneFrozen()).toBe(true); + await harness.attemptDrop(new File(["[]"], "swapped.ocsf.json")); + await expect(harness.activateDropzoneWithKeyboard()).resolves.toBe(false); + + expect(harness.selectedFileName()).toBe("findings.ocsf.json"); + // A swap would reset to ready, re-enabling submission for a second POST. + expect(harness.isUploadInProgress()).toBe(true); + + await harness.waitForTrackingStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("notifies when an import completes while the dialog is closed", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["processing", "completed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForTrackingStatus(); + await harness.closeImportFindings(); + + await harness.waitForCompletionNotification(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("offers a fresh import when reopening after a background completion", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["processing", "completed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForTrackingStatus(); + await harness.closeImportFindings(); + await harness.waitForCompletionNotification(); + + await harness.openImportFindings(); + // Without the reopen reset the summary renders in place of the dropzone and + // the submit, leaving no way to import a second report. + expect(harness.hasCompletedSummary()).toBe(false); + await harness.selectFile(new File(["[]"], "another.ocsf.json")); + expect(harness.selectedFileName()).toBe("another.ocsf.json"); + expect(harness.isImportEnabled()).toBe(true); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("notifies when the dialog closes immediately before the import completes", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + holdStatusResponse: true, + statusSequence: ["completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFirstStatusPoll(); + + await harness.closeImmediatelyBeforeStatusCompletes(); + + await harness.waitForCompletionNotification(); + expect(harness.pageRefreshCount).toBe(1); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("retries a failed terminal import with the selected file", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["failed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFailedImport(); + expect(harness.pageRefreshCount).toBe(0); + + await harness.retryUpload(); + expect(harness.ingestionPostCount).toBe(2); + }); + + it("reports how many records a failed import processed", async () => { + const harness = new ScansPageHarness(partiallyProcessedIngestionFixture()); + await harness.mount({ statusSequence: ["failed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + + await harness.waitForFailedImport(); + await harness.waitForFailedImportSummary(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("clears a terminal result after closing the dialog", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["failed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFailedImport(); + await harness.closeImportFindings(); + await harness.openImportFindings(); + + expect(harness.selectedFileName()).toBeNull(); + expect(harness.isImportEnabled()).toBe(false); + }); + + it("retries a transient status failure without re-uploading", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + statusErrorAt: 1, + statusSequence: ["processing", "completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForStatusError(); + + await harness.retryStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("holds a stuck import instead of freeing a second upload", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusErrorAt: 1 }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForStatusError(); + + // The ingestion API has no cancellation and every POST opens a new job, so + // letting go of an accepted one would only duplicate it. + expect(harness.hasStopTrackingAction()).toBe(false); + expect(harness.isDropzoneFrozen()).toBe(true); + expect(harness.isImportEnabled()).toBe(false); + + await harness.retryStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("never overlaps status polls for an accepted import", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + // The delay has to outlast POLL_INTERVAL_MS: an interval-driven poll fires + // its second request while this first one is still in flight. + await harness.mount({ + statusDelayMs: 7500, + statusSequence: ["completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + + await harness.waitForCompletedSummary(); + expect(harness.maximumInFlightStatusRequests).toBe(1); + }); + + it("stops polling a tracked import after leaving the page", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusDelayMs: 500 }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFirstStatusPoll(); + await harness.leaveScansPage(); + const pollsWhenLeaving = harness.ingestionStatusPollCount; + + // An unaborted poll answers into the dead page and chains the next one, + // refreshing and toasting over whatever route the user moved to. + await harness.waitPastTheNextPoll(); + expect(harness.ingestionStatusPollCount).toBe(pollsWhenLeaving); + }); +}); diff --git a/ui/app/api/ingestions/[ingestionId]/route.test.ts b/ui/app/api/ingestions/[ingestionId]/route.test.ts new file mode 100644 index 0000000000..f0723192d1 --- /dev/null +++ b/ui/app/api/ingestions/[ingestionId]/route.test.ts @@ -0,0 +1,231 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +import { GET } from "./route"; + +const { getAuthHeadersMock, isCloudMock } = vi.hoisted(() => ({ + getAuthHeadersMock: vi.fn(), + isCloudMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +describe("GET /api/ingestions/[ingestionId]", () => { + const server = setupServer(); + + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + + afterEach(() => { + server.resetHandlers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + afterAll(() => server.close()); + + it("returns not found in OSS and Local Server before reading the ingestion identifier", async () => { + isCloudMock.mockReturnValue(false); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(404); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("forwards a Cloud status request and translates its typed response", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json({ + data: { + id: "ingestion-123", + type: "ingestions", + attributes: { + status: "processing", + summary: { total: 5, processed: 3, invalid: 1 }, + requested_at: "2026-08-26T11:23:20.265770Z", + started_at: "2026-08-26T11:23:20.372762Z", + completed_at: null, + }, + }, + }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + await expect(response.json()).resolves.toEqual({ + data: { + id: "ingestion-123", + status: "processing", + totalRecords: 5, + processedRecords: 3, + invalidRecords: 1, + }, + }); + }); + + it("forwards an identifier that needs escaping as one encoded path segment", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + let requestedUrl = ""; + server.use( + http.get("https://api.example.com/api/v1/ingestions/*", ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json({ + data: { id: "2026/08 report", attributes: { status: "pending" } }, + }); + }), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "2026/08 report" }), + }); + + expect(requestedUrl).toBe( + "https://api.example.com/api/v1/ingestions/2026%2F08%20report", + ); + expect(response.status).toBe(200); + }); + + it("sanitizes unreadable upstream status failures", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get( + "https://api.example.com/api/v1/ingestions/ingestion-123", + () => + new HttpResponse("upstream details", { + status: 503, + headers: { "content-type": "text/html" }, + }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); + + it("returns a safe bad gateway response when the ingestion API connection fails", async () => { + // Given + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("socket hang up from api.internal")), + ); + + // When + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + // Then + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); + + it("does not expose structured upstream status failures", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json( + { errors: [{ code: "internal_error", detail: "upstream details" }] }, + { status: 503 }, + ), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); + + it("tracks a status response that has not reported its summary yet", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json({ + data: { + type: "ingestions", + id: "ingestion-123", + attributes: { status: "pending" }, + }, + }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + data: { + id: "ingestion-123", + status: "pending", + totalRecords: 0, + processedRecords: 0, + invalidRecords: 0, + }, + }); + }); + + it("rejects malformed accepted status responses", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json({ data: { id: "ingestion-123", attributes: {} } }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); +}); diff --git a/ui/app/api/ingestions/[ingestionId]/route.ts b/ui/app/api/ingestions/[ingestionId]/route.ts new file mode 100644 index 0000000000..aa4555c657 --- /dev/null +++ b/ui/app/api/ingestions/[ingestionId]/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { parseIngestion } from "@/lib/ingestions"; +import { isCloud } from "@/lib/shared/env"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +interface IngestionRouteContext { + params: Promise<{ + ingestionId: string; + }>; +} + +const INVALID_INGESTION_RESPONSE = + "Unable to retrieve the import status. Please try again."; + +export async function GET( + _request: Request, + { params }: IngestionRouteContext, +) { + if (!isCloud()) return new Response(null, { status: 404 }); + + const { ingestionId } = await params; + if (!ingestionId) return new Response(null, { status: 404 }); + + const headers = await getAuthHeaders({ contentType: false }); + let upstreamResponse: Response; + try { + upstreamResponse = await fetch( + `${apiBaseUrl}/ingestions/${encodeURIComponent(ingestionId)}`, + { headers, cache: "no-store" }, + ); + } catch { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + const payload = await upstreamResponse.json().catch(() => undefined); + + if (!upstreamResponse.ok) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: upstreamResponse.status }, + ); + } + + const ingestion = parseIngestion(payload); + if (!ingestion) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + + return NextResponse.json({ data: ingestion }); +} diff --git a/ui/app/api/ingestions/route.test.ts b/ui/app/api/ingestions/route.test.ts new file mode 100644 index 0000000000..3142c1375b --- /dev/null +++ b/ui/app/api/ingestions/route.test.ts @@ -0,0 +1,345 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +import { POST } from "./route"; + +// Browser MSW intercepts the same-origin request before Next can run this +// Route Handler, so these tests call POST directly. +const { getAuthHeadersMock, isCloudMock } = vi.hoisted(() => ({ + getAuthHeadersMock: vi.fn(), + isCloudMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +// A browser upload always reaches the route with a length on the wire, and the +// route refuses anything it cannot measure, so a forwarded Request needs one. +const uploadRequest = (body = "report") => + new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { + "content-length": String(new TextEncoder().encode(body).length), + }, + body, + }); + +describe("POST /api/ingestions", () => { + const server = setupServer(); + + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + + afterEach(() => { + server.resetHandlers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + afterAll(() => server.close()); + + it("returns not found in OSS and Local Server without authenticating or forwarding the upload", async () => { + isCloudMock.mockReturnValue(false); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const request = new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=report" }, + body: "--report--", + }); + + const response = await POST(request); + + expect(response.status).toBe(404); + expect(request.body?.locked).toBe(false); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("forwards the original multipart stream and boundary in Cloud", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + let contentType: string | null = null; + let contentLength: string | null = null; + let uploadedBody = ""; + server.use( + http.post( + "https://api.example.com/api/v1/ingestions", + async ({ request }) => { + contentType = request.headers.get("content-type"); + contentLength = request.headers.get("content-length"); + uploadedBody = await request.text(); + return HttpResponse.json({ + data: { + id: "ingestion-123", + type: "ingestions", + attributes: { + status: "pending", + summary: { total: 0, processed: 0, invalid: 0 }, + requested_at: "2026-08-26T11:23:20.265770Z", + started_at: null, + completed_at: null, + }, + }, + }); + }, + ), + ); + // Built by hand: a Request created from FormData carries no content-length + // header, which is what this test pins. + const boundary = "----ingestionBoundary"; + const multipartBody = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="findings.ocsf.json"', + "Content-Type: application/json", + "", + "finding report", + `--${boundary}--`, + "", + ].join("\r\n"); + const request = new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + "content-length": String( + new TextEncoder().encode(multipartBody).length, + ), + }, + body: multipartBody, + }); + + const response = await POST(request); + + expect(contentType).toBe(`multipart/form-data; boundary=${boundary}`); + expect(contentLength).toBe( + String(new TextEncoder().encode(multipartBody).length), + ); + expect(uploadedBody).toBe(multipartBody); + await expect(response.json()).resolves.toEqual({ + data: { + id: "ingestion-123", + status: "pending", + totalRecords: 0, + processedRecords: 0, + invalidRecords: 0, + }, + }); + }); + + it("refuses an upload it cannot measure instead of forwarding it chunked", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + // A Request built from FormData carries no content-length, and the ingestion + // API parses no file out of the chunked body that would be forwarded. + const request = new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=report" }, + body: "--report--", + }); + + const response = await POST(request); + + expect(response.status).toBe(411); + expect(fetchMock).not.toHaveBeenCalled(); + expect(request.body?.locked).toBe(false); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it("sanitizes unexpected upstream error pages", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post( + "https://api.example.com/api/v1/ingestions", + () => + new HttpResponse("upstream details", { + status: 502, + headers: { "content-type": "text/html" }, + }), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it("returns a safe bad gateway response when the ingestion API connection fails", async () => { + // Given + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED api.internal")), + ); + + // When + const response = await POST(uploadRequest()); + + // Then + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it.each([ + [400, "invalid", "The report is not a valid Prowler OCSF finding report."], + [ + 402, + "subscription_required", + "A Prowler Cloud subscription is required to import findings.", + ], + [ + 403, + "permission_denied", + "You do not have permission to import findings.", + ], + [ + 413, + "file_too_large", + "The selected file exceeds the allowed upload size.", + ], + [ + 429, + "rate_limited", + "Too many import requests. Please try again shortly.", + ], + ])( + "maps known upstream rejection %i/%s to safe import guidance", + async (status, code, message) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json( + { errors: [{ code, detail: "internal implementation detail" }] }, + { status }, + ), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: message }); + }, + ); + + const STATUS_TIER_REJECTIONS = [ + [400, "The report is not a valid Prowler OCSF finding report."], + [402, "A Prowler Cloud subscription is required to import findings."], + [403, "You do not have permission to import findings."], + [413, "The selected file exceeds the allowed upload size."], + [429, "Too many import requests. Please try again shortly."], + ] as const; + + it.each(STATUS_TIER_REJECTIONS)( + "maps a codeless upstream rejection to the %i status guidance", + async (status, message) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json( + { detail: "internal implementation detail" }, + { status }, + ), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: message }); + }, + ); + + it.each(STATUS_TIER_REJECTIONS)( + "falls through an unrecognized error code to the %i status guidance", + async (status, message) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json( + { + errors: [ + { + code: "invalid_findings", + detail: "internal implementation detail", + }, + ], + }, + { status }, + ), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: message }); + }, + ); + + // An empty id parses into a trackable-looking job whose poll URL, + // `/api/ingestions/`, matches no route: a created import reads as a failure. + it.each([ + ["no job identifier", { attributes: { status: "pending" } }], + ["an empty job identifier", { id: "", attributes: { status: "pending" } }], + ])("refuses an accepted response with %s", async (_shape, data) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json({ data }), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it("rejects accepted responses that cannot start a trackable ingestion", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json({ data: { id: "ingestion-123", attributes: {} } }), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); +}); diff --git a/ui/app/api/ingestions/route.ts b/ui/app/api/ingestions/route.ts new file mode 100644 index 0000000000..d8bf4a0ce6 --- /dev/null +++ b/ui/app/api/ingestions/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { parseIngestion } from "@/lib/ingestions"; +import { isCloud } from "@/lib/shared/env"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const INVALID_INGESTION_RESPONSE = + "Unable to start the import. Please try again."; +const INGESTION_REJECTION_BY_CODE = { + invalid: "The report is not a valid Prowler OCSF finding report.", + subscription_required: + "A Prowler Cloud subscription is required to import findings.", + permission_denied: "You do not have permission to import findings.", + file_too_large: "The selected file exceeds the allowed upload size.", + rate_limited: "Too many import requests. Please try again shortly.", +} as const; + +const INGESTION_REJECTION_BY_STATUS = { + 400: INGESTION_REJECTION_BY_CODE.invalid, + 402: INGESTION_REJECTION_BY_CODE.subscription_required, + 403: INGESTION_REJECTION_BY_CODE.permission_denied, + 413: INGESTION_REJECTION_BY_CODE.file_too_large, + 429: INGESTION_REJECTION_BY_CODE.rate_limited, +} as const; + +const ingestionRejectionMessage = ( + payload: unknown, + status: number, + fallback: string, +): string => { + if (typeof payload === "object" && payload !== null && "errors" in payload) { + const errors = payload.errors; + if (Array.isArray(errors) && typeof errors[0]?.code === "string") { + const message = + INGESTION_REJECTION_BY_CODE[ + errors[0].code as keyof typeof INGESTION_REJECTION_BY_CODE + ]; + if (message) return message; + } + } + + return ( + INGESTION_REJECTION_BY_STATUS[ + status as keyof typeof INGESTION_REJECTION_BY_STATUS + ] ?? fallback + ); +}; + +export async function POST(request: Request) { + if (!isCloud()) return new Response(null, { status: 404 }); + + const headers = await getAuthHeaders({ contentType: false }); + const contentType = request.headers.get("content-type"); + const contentLength = request.headers.get("content-length"); + + if (contentType) headers["Content-Type"] = contentType; + // Without the length the stream is forwarded chunked, and the ingestion API + // parses no file out of a chunked multipart body: refuse rather than spend + // the upload on a request that cannot succeed. + if (!contentLength) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 411 }, + ); + } + headers["Content-Length"] = contentLength; + + const upstreamRequest: RequestInit & { duplex: "half" } = { + method: "POST", + headers, + body: request.body, + duplex: "half", + cache: "no-store", + }; + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(`${apiBaseUrl}/ingestions`, upstreamRequest); + } catch { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + const payload = await upstreamResponse.json().catch(() => undefined); + + if (!upstreamResponse.ok) { + return NextResponse.json( + { + error: ingestionRejectionMessage( + payload, + upstreamResponse.status, + INVALID_INGESTION_RESPONSE, + ), + }, + { status: upstreamResponse.status }, + ); + } + + const ingestion = parseIngestion(payload); + if (!ingestion) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + + return NextResponse.json( + { data: ingestion }, + { status: upstreamResponse.status }, + ); +} diff --git a/ui/auth.config.test.ts b/ui/auth.config.test.ts index a95461cb59..5815f31496 100644 --- a/ui/auth.config.test.ts +++ b/ui/auth.config.test.ts @@ -35,8 +35,11 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = { manage_account: false, manage_providers: false, manage_scans: false, + manage_ingestions: false, manage_integrations: false, + manage_billing: false, manage_alerts: false, + manage_lighthouse_ai_configuration: false, unlimited_visibility: false, }; diff --git a/ui/auth.config.ts b/ui/auth.config.ts index ec6880e193..c741534d24 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -56,6 +56,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = { manage_account: false, manage_providers: false, manage_scans: false, + manage_ingestions: false, manage_integrations: false, manage_billing: false, manage_alerts: false, @@ -95,7 +96,7 @@ const toTokenUser = (user?: TokenUserInput): TokenUser => email: user?.email ?? undefined, companyName: user?.companyName ?? user?.company, dateJoined: user?.dateJoined, - permissions: user?.permissions ?? { ...DEFAULT_PERMISSIONS }, + permissions: { ...DEFAULT_PERMISSIONS, ...user?.permissions }, }) as TokenUser; type UserMeResponse = Awaited>; diff --git a/ui/changelog.d/import-findings-drag-drop.added.md b/ui/changelog.d/import-findings-drag-drop.added.md new file mode 100644 index 0000000000..ee9c9b931c --- /dev/null +++ b/ui/changelog.d/import-findings-drag-drop.added.md @@ -0,0 +1 @@ +Finding-report imports from Scans for Cloud and Private Cloud deployments diff --git a/ui/components/scans/import-findings-modal.tsx b/ui/components/scans/import-findings-modal.tsx new file mode 100644 index 0000000000..c367829ea2 --- /dev/null +++ b/ui/components/scans/import-findings-modal.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/shadcn/button/button"; +import { FileUploadDropzone } from "@/components/shadcn/file-upload/file-upload-dropzone"; +import { Modal } from "@/components/shadcn/modal/modal"; +import { useToast } from "@/components/shadcn/toast/use-toast"; +import { type Ingestion, type IngestionResponse } from "@/types"; + +import { + type IngestionPollingTarget, + useIngestionPolling, +} from "./use-ingestion-polling"; + +const IMPORT_STATE = { + IDLE: "idle", + READY: "ready", + UPLOADING: "uploading", + TRACKING: "tracking", + TRACKING_ERROR: "tracking-error", + COMPLETED: "completed", + FAILED: "failed", + INVALID: "invalid", +} as const; + +interface IdleImportState { + type: typeof IMPORT_STATE.IDLE; +} + +interface ReadyImportState { + type: typeof IMPORT_STATE.READY; + file: File; +} + +interface UploadingImportState { + type: typeof IMPORT_STATE.UPLOADING; + file: File; +} + +interface TrackingImportState { + type: typeof IMPORT_STATE.TRACKING; + file: File; + ingestion: Ingestion; +} + +interface TrackingErrorImportState { + type: typeof IMPORT_STATE.TRACKING_ERROR; + error: string; + file: File; + ingestion: Ingestion; +} + +interface CompletedImportState { + type: typeof IMPORT_STATE.COMPLETED; + ingestion: Ingestion; +} + +interface FailedImportState { + type: typeof IMPORT_STATE.FAILED; + error: string; + file: File; + // Absent when the upload itself was rejected: no job, so no counters. + ingestion?: Ingestion; +} + +interface InvalidImportState { + type: typeof IMPORT_STATE.INVALID; + error: string; +} + +type ImportState = + | IdleImportState + | ReadyImportState + | UploadingImportState + | TrackingImportState + | TrackingErrorImportState + | CompletedImportState + | FailedImportState + | InvalidImportState; + +const validateFile = (file: File): string | null => { + if (!file.name.toLowerCase().endsWith(".ocsf.json")) { + return "Choose a Prowler .ocsf.json finding report."; + } + if (file.size === 0) return "The selected file is empty."; + return null; +}; + +const START_ERROR = "Unable to start the import. Please try again."; + +const responseError = async ( + response: Response, + fallback: string, +): Promise => { + const payload = await response.json().catch(() => undefined); + if ( + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof payload.error === "string" + ) { + return payload.error; + } + return fallback; +}; + +export function ImportFindingsModal() { + const router = useRouter(); + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [state, setState] = useState({ type: IMPORT_STATE.IDLE }); + const polling = useIngestionPolling({ + onCompleted: (ingestion, completedWhileHidden) => { + router.refresh(); + if (completedWhileHidden) { + toast({ + title: "Findings import completed", + description: "Imported findings are now available in Scans.", + }); + } + setState({ type: IMPORT_STATE.COMPLETED, ingestion }); + }, + onFailed: ({ file, ingestion }) => { + setState({ + type: IMPORT_STATE.FAILED, + error: + "Import failed. Retry the same file or select a different report.", + file, + ingestion, + }); + }, + onProgress: ({ file, ingestion }) => { + setState({ type: IMPORT_STATE.TRACKING, file, ingestion }); + }, + onTrackingError: ({ file, ingestion }, error) => { + setState({ + type: IMPORT_STATE.TRACKING_ERROR, + error, + file, + ingestion, + }); + }, + }); + + const handleFileSelect = (file?: File) => { + if (!file) { + setState({ type: IMPORT_STATE.IDLE }); + return; + } + + const error = validateFile(file); + setState( + error + ? { type: IMPORT_STATE.INVALID, error } + : { type: IMPORT_STATE.READY, file }, + ); + }; + + const handleOpenChange = (nextOpen: boolean) => { + polling.setDialogVisible(nextOpen); + // A job that completed while hidden leaves a summary nobody dismissed. + // Reset it on the next open so the dropzone is available again. + if (nextOpen && state.type === IMPORT_STATE.COMPLETED) { + setState({ type: IMPORT_STATE.IDLE }); + } + // Dismissing never aborts the request: resetting here would drop the + // accepted job and re-enable submit for a second, concurrent POST. + if ( + !nextOpen && + state.type !== IMPORT_STATE.UPLOADING && + state.type !== IMPORT_STATE.TRACKING && + state.type !== IMPORT_STATE.TRACKING_ERROR + ) { + setState({ type: IMPORT_STATE.IDLE }); + } + setOpen(nextOpen); + }; + + const upload = async (file: File) => { + setState({ type: IMPORT_STATE.UPLOADING, file }); + const formData = new FormData(); + formData.set("file", file); + const response = await fetch("/api/ingestions", { + method: "POST", + body: formData, + }).catch(() => undefined); + if (!response || !response.ok) { + setState({ + type: IMPORT_STATE.FAILED, + error: response + ? await responseError(response, START_ERROR) + : START_ERROR, + file, + }); + return; + } + + const payload = (await response.json()) as IngestionResponse; + const target: IngestionPollingTarget = { file, ingestion: payload.data }; + if (!polling.start(target)) return; + setState({ type: IMPORT_STATE.TRACKING, ...target }); + }; + + const submit = async () => { + if (state.type !== IMPORT_STATE.READY) return; + await upload(state.file); + }; + + const file = + state.type === IMPORT_STATE.READY || + state.type === IMPORT_STATE.UPLOADING || + state.type === IMPORT_STATE.FAILED || + state.type === IMPORT_STATE.TRACKING || + state.type === IMPORT_STATE.TRACKING_ERROR + ? state.file + : undefined; + const isSubmitting = state.type === IMPORT_STATE.UPLOADING; + const completed = state.type === IMPORT_STATE.COMPLETED; + + return ( + <> + + +
+ {completed ? ( +

+ Import completed: {state.ingestion.totalRecords} total records,{" "} + {state.ingestion.processedRecords} processed,{" "} + {state.ingestion.invalidRecords} invalid. +

+ ) : ( + <> +
+ +
+ {state.type === IMPORT_STATE.INVALID && ( +

{state.error}

+ )} + {state.type === IMPORT_STATE.FAILED && ( + <> +

{state.error}

+ {state.ingestion && ( +

+ Reported progress: {state.ingestion.processedRecords} of{" "} + {state.ingestion.totalRecords} records processed,{" "} + {state.ingestion.invalidRecords} invalid. +

+ )} + + + )} + {state.type === IMPORT_STATE.TRACKING && ( +

Import is {state.ingestion.status}.

+ )} + {state.type === IMPORT_STATE.TRACKING_ERROR && ( + <> +

{state.error}

+ + + )} + + + )} +
+
+ + ); +} diff --git a/ui/components/scans/scans-page-shell.test.tsx b/ui/components/scans/scans-page-shell.test.tsx index 5d953e1ec3..b702f2cdf7 100644 --- a/ui/components/scans/scans-page-shell.test.tsx +++ b/ui/components/scans/scans-page-shell.test.tsx @@ -178,24 +178,6 @@ describe("ScansPageShell", () => { useScansStore.getState().closeLaunchScanModal(); }); - it("does not render an imported findings tab", () => { - vi.stubEnv("UI_CLOUD_ENABLED", "false"); - - render( - -
Scans table
-
, - ); - - expect( - screen.queryByRole("tab", { name: /imported findings/i }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /import findings/i }), - ).not.toBeInTheDocument(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - it("uses the shared scan filter bar for scan filters", () => { vi.stubEnv("UI_CLOUD_ENABLED", "false"); @@ -245,6 +227,72 @@ describe("ScansPageShell", () => { expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible(); }); + it.each(["Cloud", "Private Cloud"])( + "shows Import Findings in %s with Manage Ingestions", + () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + render( + +
Scans table
+
, + ); + + // Then + expect( + screen.getByRole("button", { name: /import findings/i }), + ).toBeVisible(); + }, + ); + + it("hides Import Findings without Manage Ingestions", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + render( + +
Scans table
+
, + ); + + // Then + expect( + screen.queryByRole("button", { name: /import findings/i }), + ).not.toBeInTheDocument(); + }); + + it("hides Import Findings in OSS and Local Server", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + render( + +
Scans table
+
, + ); + + // Then + expect( + screen.queryByRole("button", { name: /import findings/i }), + ).not.toBeInTheDocument(); + }); + it("shows the CLI import banner in Cloud", () => { vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx index 8209957879..6af2c436fd 100644 --- a/ui/components/scans/scans-page-shell.tsx +++ b/ui/components/scans/scans-page-shell.tsx @@ -28,6 +28,7 @@ import type { ScanScheduleCapability } from "@/types/schedules"; const viewFirstScanFlow = getFlowById("view-first-scan")!; import { CliImportBanner } from "./cli-import-banner"; +import { ImportFindingsModal } from "./import-findings-modal"; import { LaunchScanModal } from "./launch-scan-modal"; import { ScansFilterBar } from "./scans-filter-bar"; import { ScansProvidersEmptyState } from "./scans-providers-empty-state"; @@ -37,6 +38,7 @@ interface ScansPageShellProps { providers: ProviderProps[]; providerGroups?: ProviderGroup[]; hasManageScansPermission: boolean; + hasManageIngestionsPermission?: boolean; activeScanCount?: number; children: ReactNode; /** Cloud overlay seam for the launch-scan modal. */ @@ -48,6 +50,7 @@ export function ScansPageShell({ providers, providerGroups = [], hasManageScansPermission, + hasManageIngestionsPermission = false, activeScanCount = 0, children, scanScheduleCapability, @@ -155,6 +158,9 @@ export function ScansPageShell({ > Launch Scan + {isCloudEnvironment && hasManageIngestionsPermission && ( + + )} {isCloudEnvironment && } diff --git a/ui/components/scans/use-ingestion-polling.ts b/ui/components/scans/use-ingestion-polling.ts new file mode 100644 index 0000000000..fb9bab5729 --- /dev/null +++ b/ui/components/scans/use-ingestion-polling.ts @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { INGESTION_STATUS, type Ingestion } from "@/types"; + +const POLL_INTERVAL_MS = 5000; +// 60 polls at 5s = ~5 min of watching; timing out ends the watch, not the job. +const MAX_POLL_ATTEMPTS = 60; +const STATUS_ERROR = "Unable to check the import status. Please try again."; +const STATUS_TIMEOUT = + "Import is taking longer than expected — it may still be running in the background."; + +export interface IngestionPollingTarget { + file: File; + ingestion: Ingestion; +} + +interface UseIngestionPollingOptions { + onCompleted: (ingestion: Ingestion, completedWhileHidden: boolean) => void; + onFailed: (target: IngestionPollingTarget) => void; + onProgress: (target: IngestionPollingTarget) => void; + onTrackingError: (target: IngestionPollingTarget, error: string) => void; +} + +const isTerminal = (ingestion: Ingestion): boolean => + ingestion.status === INGESTION_STATUS.COMPLETED || + ingestion.status === INGESTION_STATUS.FAILED; + +const responseError = async ( + response: Response, + fallback: string, +): Promise => { + const payload = await response.json().catch(() => undefined); + if ( + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof payload.error === "string" + ) { + return payload.error; + } + return fallback; +}; + +export const useIngestionPolling = ({ + onCompleted, + onFailed, + onProgress, + onTrackingError, +}: UseIngestionPollingOptions) => { + const controllerRef = useRef(null); + const dialogVisibleRef = useRef(false); + const mountedRef = useRef(true); + const timeoutRef = useRef(null); + + const stop = () => { + controllerRef.current?.abort(); + controllerRef.current = null; + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const setDialogVisible = (visible: boolean) => { + dialogVisibleRef.current = visible; + }; + + const start = (initialTarget: IngestionPollingTarget): boolean => { + if (!mountedRef.current) return false; + stop(); + + const controller = new AbortController(); + controllerRef.current = controller; + let attempts = 0; + let target = initialTarget; + + const finish = () => { + if (controllerRef.current === controller) { + controllerRef.current = null; + } + timeoutRef.current = null; + }; + + const poll = async () => { + attempts += 1; + + try { + const response = await fetch(`/api/ingestions/${target.ingestion.id}`, { + signal: controller.signal, + }); + if (!response.ok) { + const error = await responseError(response, STATUS_ERROR); + if (controller.signal.aborted) return; + finish(); + onTrackingError(target, error); + return; + } + + const payload = (await response.json()) as { + data?: Ingestion; + }; + if (controller.signal.aborted) return; + + const ingestion = payload.data; + if (!ingestion || !isTerminal(ingestion)) { + target = { + file: target.file, + ingestion: ingestion ?? target.ingestion, + }; + + if (attempts >= MAX_POLL_ATTEMPTS) { + finish(); + onTrackingError(target, STATUS_TIMEOUT); + return; + } + + onProgress(target); + timeoutRef.current = window.setTimeout( + () => void poll(), + POLL_INTERVAL_MS, + ); + return; + } + + finish(); + if (ingestion.status === INGESTION_STATUS.COMPLETED) { + onCompleted(ingestion, !dialogVisibleRef.current); + return; + } + + onFailed({ file: target.file, ingestion }); + } catch { + if (controller.signal.aborted) return; + finish(); + onTrackingError(target, STATUS_ERROR); + } + }; + + void poll(); + return true; + }; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + controllerRef.current?.abort(); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + return { setDialogVisible, start, stop }; +}; diff --git a/ui/components/shadcn/file-upload/file-upload-dropzone.tsx b/ui/components/shadcn/file-upload/file-upload-dropzone.tsx index 120bdea59d..e15cb2c25c 100644 --- a/ui/components/shadcn/file-upload/file-upload-dropzone.tsx +++ b/ui/components/shadcn/file-upload/file-upload-dropzone.tsx @@ -1,10 +1,27 @@ "use client"; import { FileUp } from "lucide-react"; -import { type DragEvent, type ReactNode, useId, useState } from "react"; +import { + type ChangeEvent, + type DragEvent, + type KeyboardEvent, + type ReactNode, + useId, + useRef, + useState, +} from "react"; import { cn } from "@/lib/utils"; +const KB = 1024; +const MB = KB * 1024; + +// Not toLocaleString: a locale-grouped "15.002 KB" reads as 15 KB in es-ES. +function formatFileSize(bytes: number) { + const kb = Math.ceil(bytes / KB); + return kb < KB ? `${kb} KB` : `${(bytes / MB).toFixed(1)} MB`; +} + interface FileUploadDropzoneProps { file?: File | null; onFileSelect: (file?: File) => void; @@ -14,6 +31,7 @@ interface FileUploadDropzoneProps { emptyDescription?: string; selectText?: string; icon?: ReactNode; + disabled?: boolean; } export function FileUploadDropzone({ @@ -25,21 +43,40 @@ export function FileUploadDropzone({ emptyDescription = "or", selectText = "Select File", icon = , + disabled = false, }: FileUploadDropzoneProps) { const inputId = useId(); + const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const handleDrop = (event: DragEvent) => { event.preventDefault(); setIsDragging(false); + if (disabled) return; onFileSelect(event.dataTransfer.files[0]); }; + const handleKeyDown = (event: KeyboardEvent) => { + if (disabled || (event.key !== "Enter" && event.key !== " ")) return; + event.preventDefault(); + inputRef.current?.click(); + }; + + const handleChange = (event: ChangeEvent) => { + onFileSelect(event.target.files?.[0]); + event.target.value = ""; + }; + return ( ); diff --git a/ui/hooks/use-auth.ts b/ui/hooks/use-auth.ts index 0946fc7677..4e5d5ea76c 100644 --- a/ui/hooks/use-auth.ts +++ b/ui/hooks/use-auth.ts @@ -11,6 +11,7 @@ export function useAuth() { manage_account: false, manage_providers: false, manage_scans: false, + manage_ingestions: false, manage_integrations: false, manage_billing: false, manage_alerts: false, diff --git a/ui/lib/ingestions.ts b/ui/lib/ingestions.ts new file mode 100644 index 0000000000..ed783212b7 --- /dev/null +++ b/ui/lib/ingestions.ts @@ -0,0 +1,55 @@ +import { + INGESTION_STATUS, + type Ingestion, + type IngestionStatus, +} from "@/types"; + +interface JsonApiIngestionSummary { + total?: unknown; + processed?: unknown; + invalid?: unknown; +} + +interface JsonApiIngestionAttributes { + status?: unknown; + summary?: JsonApiIngestionSummary; +} + +interface JsonApiIngestionData { + id?: unknown; + attributes?: JsonApiIngestionAttributes; +} + +interface JsonApiIngestionResponse { + data?: JsonApiIngestionData; +} + +const isIngestionStatus = (value: unknown): value is IngestionStatus => + Object.values(INGESTION_STATUS).includes(value as IngestionStatus); + +// Counts are absent until the job reports them: read as 0, not a failure. +const recordCount = (value: unknown): number => + typeof value === "number" ? value : 0; + +export const parseIngestion = (payload: unknown): Ingestion | null => { + const data = (payload as JsonApiIngestionResponse)?.data; + const attributes = data?.attributes; + + if ( + typeof data?.id !== "string" || + data.id === "" || + !isIngestionStatus(attributes?.status) + ) { + return null; + } + + const summary = attributes.summary; + + return { + id: data.id, + status: attributes.status, + totalRecords: recordCount(summary?.total), + processedRecords: recordCount(summary?.processed), + invalidRecords: recordCount(summary?.invalid), + }; +}; diff --git a/ui/types/index.ts b/ui/types/index.ts index 213f41f5b9..ac5812aa20 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -5,6 +5,7 @@ export * from "./filters"; export * from "./findings-table"; export * from "./findings-triage"; export * from "./formSchemas"; +export * from "./ingestions"; export * from "./organizations"; export * from "./processors"; export * from "./provider-wizard"; diff --git a/ui/types/ingestions.ts b/ui/types/ingestions.ts new file mode 100644 index 0000000000..b3732d0ddc --- /dev/null +++ b/ui/types/ingestions.ts @@ -0,0 +1,21 @@ +export const INGESTION_STATUS = { + PENDING: "pending", + PROCESSING: "processing", + COMPLETED: "completed", + FAILED: "failed", +} as const; + +export type IngestionStatus = + (typeof INGESTION_STATUS)[keyof typeof INGESTION_STATUS]; + +export interface Ingestion { + id: string; + status: IngestionStatus; + totalRecords: number; + processedRecords: number; + invalidRecords: number; +} + +export interface IngestionResponse { + data: Ingestion; +} diff --git a/ui/types/users.ts b/ui/types/users.ts index c9b43494b3..56dfe7c3e5 100644 --- a/ui/types/users.ts +++ b/ui/types/users.ts @@ -87,6 +87,7 @@ export const PERMISSION_KEY = { MANAGE_ACCOUNT: "manage_account", MANAGE_PROVIDERS: "manage_providers", MANAGE_SCANS: "manage_scans", + MANAGE_INGESTIONS: "manage_ingestions", MANAGE_INTEGRATIONS: "manage_integrations", MANAGE_BILLING: "manage_billing", MANAGE_ALERTS: "manage_alerts", @@ -98,7 +99,7 @@ export type PermissionKey = (typeof PERMISSION_KEY)[keyof typeof PERMISSION_KEY]; export type RolePermissionAttributes = Pick< - RoleDetail["attributes"], + RoleDetailAttributes, PermissionKey >; @@ -110,43 +111,54 @@ export const TENANT_MEMBERSHIP_ROLE = { export type TenantMembershipRole = (typeof TENANT_MEMBERSHIP_ROLE)[keyof typeof TENANT_MEMBERSHIP_ROLE]; +export interface RoleDetailAttributes { + name: string; + manage_users: boolean; + manage_account: boolean; + manage_providers: boolean; + manage_scans: boolean; + manage_ingestions?: boolean; + manage_integrations: boolean; + manage_billing?: boolean; + manage_alerts?: boolean; + manage_lighthouse_ai_configuration?: boolean; + unlimited_visibility: boolean; + permission_state?: string; + inserted_at?: string; + updated_at?: string; +} + export interface RoleDetail { id: string; type: "roles"; - attributes: { - name: string; - manage_users: boolean; - manage_account: boolean; - manage_providers: boolean; - manage_scans: boolean; - manage_integrations: boolean; - manage_billing?: boolean; - manage_alerts?: boolean; - manage_lighthouse_ai_configuration?: boolean; - unlimited_visibility: boolean; - permission_state?: string; - inserted_at?: string; - updated_at?: string; - }; + attributes: RoleDetailAttributes; +} + +export interface MembershipDetailAttributes { + role: string; + date_joined: string; + [key: string]: unknown; +} + +export interface MembershipTenantIdentifier { + type: string; + id: string; +} + +export interface MembershipTenantRelationship { + data: MembershipTenantIdentifier; +} + +export interface MembershipDetailRelationships { + tenant: MembershipTenantRelationship; + [key: string]: unknown; } export interface MembershipDetailData { id: string; type: "memberships"; - attributes: { - role: string; - date_joined: string; - [key: string]: any; - }; - relationships: { - tenant: { - data: { - type: string; - id: string; - }; - }; - [key: string]: any; - }; + attributes: MembershipDetailAttributes; + relationships: MembershipDetailRelationships; } export interface UserDataWithRoles