mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): import Prowler OCSF findings from the Scans page (#12554)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
1679094f22
commit
0715619435
@@ -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,
|
||||
});
|
||||
@@ -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<void>;
|
||||
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));
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Suspense
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// Aliased: `useRouter()` inside a class trips rules-of-hooks, and under the
|
||||
// suite's mock it is a plain getter for the shared router singleton.
|
||||
import { useRouter as readMockedRouter } from "next/navigation";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { BrowserHarness } from "@/__tests__/browser-harness";
|
||||
import { handlersForIngestion } from "@/__tests__/msw/handlers/ingestions";
|
||||
import type {
|
||||
IngestionFixture,
|
||||
IngestionRejectionFixture,
|
||||
} from "@/__tests__/msw/handlers/ingestions.fixtures";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { ScansPageShell } from "@/components/scans/scans-page-shell";
|
||||
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
|
||||
import { SCAN_JOBS_TAB } from "@/types";
|
||||
|
||||
interface MountOptions {
|
||||
hasManageIngestionsPermission?: boolean;
|
||||
hasManageScansPermission?: boolean;
|
||||
holdStatusResponse?: boolean;
|
||||
uploadRejection?: IngestionRejectionFixture;
|
||||
uploadDelayMs?: number;
|
||||
statusErrorAt?: number;
|
||||
statusDelayMs?: number;
|
||||
statusSequence?: Array<"processing" | "completed" | "failed">;
|
||||
}
|
||||
|
||||
export class ScansPageHarness extends BrowserHarness<IngestionFixture> {
|
||||
private maxInFlightStatusRequests = 0;
|
||||
private mounted: ReturnType<typeof render> | 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<void> {
|
||||
const statusResponseGate = holdStatusResponse
|
||||
? new Promise<void>((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(
|
||||
<ScansPageShell
|
||||
providers={[]}
|
||||
hasManageIngestionsPermission={hasManageIngestionsPermission}
|
||||
hasManageScansPermission={hasManageScansPermission}
|
||||
>
|
||||
<ScanJobsTable data={[]} tab={SCAN_JOBS_TAB.ACTIVE} />
|
||||
</ScansPageShell>,
|
||||
);
|
||||
}
|
||||
|
||||
async leaveScansPage(): Promise<void> {
|
||||
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<void> {
|
||||
await this.clickButton(/Import Findings/);
|
||||
await this.waitForText(/Import findings/i);
|
||||
}
|
||||
|
||||
async selectFile(file: File): Promise<void> {
|
||||
const input = await this.waitFor(() =>
|
||||
this.container.querySelector<HTMLInputElement>('input[type="file"]'),
|
||||
);
|
||||
await this.user.upload(input, file);
|
||||
}
|
||||
|
||||
async dropFile(file: File): Promise<void> {
|
||||
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<void> {
|
||||
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<HTMLInputElement>(
|
||||
'[data-testid="import-findings-dropzone"] input[type="file"]',
|
||||
);
|
||||
return (
|
||||
zone?.getAttribute("aria-disabled") === "true" && input?.disabled === true
|
||||
);
|
||||
}
|
||||
|
||||
async activateDropzoneWithKeyboard(): Promise<boolean> {
|
||||
const dropzone = await this.waitFor(() =>
|
||||
this.q('[data-testid="import-findings-dropzone"] [role="button"]'),
|
||||
);
|
||||
const input = await this.waitFor(() =>
|
||||
this.container.querySelector<HTMLInputElement>('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<void> {
|
||||
await this.waitForText(message);
|
||||
}
|
||||
|
||||
async waitForUploadError(message: RegExp): Promise<void> {
|
||||
await this.waitForText(message);
|
||||
}
|
||||
|
||||
async waitForUploadInProgress(): Promise<void> {
|
||||
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<void> {
|
||||
await this.user.keyboard("[Escape]");
|
||||
await this.waitFor(() =>
|
||||
this.q('[role="dialog"]') === null ? true : null,
|
||||
);
|
||||
}
|
||||
|
||||
async closeImmediatelyBeforeStatusCompletes(): Promise<void> {
|
||||
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<void> {
|
||||
await this.clickButton(/Start import/i);
|
||||
}
|
||||
|
||||
async retryUpload(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.waitFor(
|
||||
() => this.hasCompletionNotification(),
|
||||
15000,
|
||||
"the import completion notification",
|
||||
);
|
||||
}
|
||||
|
||||
async waitForTrackingStatus(): Promise<void> {
|
||||
await this.waitForText(/Import is processing/i);
|
||||
}
|
||||
|
||||
async waitForStatusError(): Promise<void> {
|
||||
await this.waitForText(/Unable to retrieve the import status/i, 15000);
|
||||
}
|
||||
|
||||
async waitForFailedImport(): Promise<void> {
|
||||
await this.waitForText(/Import failed/i, 15000);
|
||||
}
|
||||
|
||||
async waitForFailedImportSummary(): Promise<void> {
|
||||
const { processedRecords, totalRecords, invalidRecords } = this.fixture;
|
||||
await this.waitForText(
|
||||
new RegExp(
|
||||
`${processedRecords} of ${totalRecords} records processed, ${invalidRecords} invalid`,
|
||||
"i",
|
||||
),
|
||||
15000,
|
||||
);
|
||||
}
|
||||
|
||||
async retryStatus(): Promise<void> {
|
||||
await this.clickButton(/Retry status/i);
|
||||
await this.waitFor(() =>
|
||||
this.ingestionStatusPollCount >= 2 ? true : null,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForFirstStatusPoll(): Promise<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("<html><body>upstream details</body></html>", {
|
||||
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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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("<html><body>upstream details</body></html>", {
|
||||
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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -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<ReturnType<typeof getUserByMe>>;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Finding-report imports from Scans for Cloud and Private Cloud deployments
|
||||
@@ -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<string> => {
|
||||
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<ImportState>({ 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 (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={() => handleOpenChange(true)}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Import Findings
|
||||
</Button>
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Import findings"
|
||||
description="Upload a Prowler OCSF finding report to add it to Scans."
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{completed ? (
|
||||
<p>
|
||||
Import completed: {state.ingestion.totalRecords} total records,{" "}
|
||||
{state.ingestion.processedRecords} processed,{" "}
|
||||
{state.ingestion.invalidRecords} invalid.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div data-testid="import-findings-dropzone">
|
||||
<FileUploadDropzone
|
||||
file={file}
|
||||
accept=".ocsf.json,application/json"
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
state.type === IMPORT_STATE.TRACKING ||
|
||||
state.type === IMPORT_STATE.TRACKING_ERROR
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{state.type === IMPORT_STATE.INVALID && (
|
||||
<p role="alert">{state.error}</p>
|
||||
)}
|
||||
{state.type === IMPORT_STATE.FAILED && (
|
||||
<>
|
||||
<p role="alert">{state.error}</p>
|
||||
{state.ingestion && (
|
||||
<p>
|
||||
Reported progress: {state.ingestion.processedRecords} of{" "}
|
||||
{state.ingestion.totalRecords} records processed,{" "}
|
||||
{state.ingestion.invalidRecords} invalid.
|
||||
</p>
|
||||
)}
|
||||
<Button type="button" onClick={() => void upload(state.file)}>
|
||||
Retry import
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{state.type === IMPORT_STATE.TRACKING && (
|
||||
<p>Import is {state.ingestion.status}.</p>
|
||||
)}
|
||||
{state.type === IMPORT_STATE.TRACKING_ERROR && (
|
||||
<>
|
||||
<p role="alert">{state.error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target: IngestionPollingTarget = {
|
||||
file: state.file,
|
||||
ingestion: state.ingestion,
|
||||
};
|
||||
setState({
|
||||
type: IMPORT_STATE.TRACKING,
|
||||
...target,
|
||||
});
|
||||
polling.start(target);
|
||||
}}
|
||||
>
|
||||
Retry status
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={state.type !== IMPORT_STATE.READY || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Importing..." : "Start import"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -178,24 +178,6 @@ describe("ScansPageShell", () => {
|
||||
useScansStore.getState().closeLaunchScanModal();
|
||||
});
|
||||
|
||||
it("does not render an imported findings tab", () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ScansPageShell
|
||||
providers={providers}
|
||||
hasManageScansPermission
|
||||
hasManageIngestionsPermission
|
||||
>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ScansPageShell
|
||||
providers={providers}
|
||||
hasManageScansPermission
|
||||
hasManageIngestionsPermission={false}
|
||||
>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ScansPageShell
|
||||
providers={providers}
|
||||
hasManageScansPermission
|
||||
hasManageIngestionsPermission
|
||||
>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
// 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");
|
||||
|
||||
|
||||
@@ -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
|
||||
</Button>
|
||||
{isCloudEnvironment && hasManageIngestionsPermission && (
|
||||
<ImportFindingsModal />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCloudEnvironment && <CliImportBanner />}
|
||||
|
||||
@@ -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<string> => {
|
||||
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<AbortController | null>(null);
|
||||
const dialogVisibleRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const timeoutRef = useRef<number | null>(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 };
|
||||
};
|
||||
@@ -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 = <FileUp className="text-text-neutral-secondary size-6" />,
|
||||
disabled = false,
|
||||
}: FileUploadDropzoneProps) {
|
||||
const inputId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (disabled) return;
|
||||
onFileSelect(event.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLLabelElement>) => {
|
||||
if (disabled || (event.key !== "Enter" && event.key !== " ")) return;
|
||||
event.preventDefault();
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onFileSelect(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
@@ -48,6 +85,7 @@ export function FileUploadDropzone({
|
||||
"border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors",
|
||||
isDragging &&
|
||||
"border-border-input-primary-press bg-bg-neutral-tertiary",
|
||||
disabled && "hover:bg-bg-neutral-primary cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -56,9 +94,7 @@ export function FileUploadDropzone({
|
||||
{file ? file.name : title}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{file
|
||||
? `${Math.ceil(file.size / 1024).toLocaleString()} KB`
|
||||
: emptyDescription}
|
||||
{file ? formatFileSize(file.size) : emptyDescription}
|
||||
</span>
|
||||
{!file && (
|
||||
<span className="text-button-tertiary text-sm font-medium">
|
||||
@@ -67,10 +103,12 @@ export function FileUploadDropzone({
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={(event) => onFileSelect(event.target.files?.[0])}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+42
-30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user