feat(ui): import Prowler OCSF findings from the Scans page (#12554)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-08-31 17:14:12 +02:00
committed by GitHub
co-authored by alejandrobailo
parent 1679094f22
commit 0715619435
24 changed files with 2314 additions and 54 deletions
@@ -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,
});
+100
View File
@@ -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));
}),
];
};
+23
View File
@@ -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 });
+1
View File
@@ -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,
+4
View File
@@ -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 });
}
+345
View File
@@ -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.",
});
});
});
+114
View File
@@ -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 },
);
}
+3
View File
@@ -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
View File
@@ -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>
</>
);
}
+66 -18
View File
@@ -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");
+6
View File
@@ -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>
);
+1
View File
@@ -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,
+55
View File
@@ -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),
};
};
+1
View File
@@ -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";
+21
View File
@@ -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
View File
@@ -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