mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
feat(ui): re-check a resource with a partial scan from the findings actions (#12879)
This commit is contained in:
@@ -215,3 +215,72 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptFindingsByResourceResponse — provider id", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should carry the provider id resolved through the scan include", () => {
|
||||
// Given — scan.provider include path, as the drawer requests it
|
||||
createDictMock.mockImplementation((type: string) => {
|
||||
if (type === "scans") {
|
||||
return {
|
||||
"scan-1": {
|
||||
id: "scan-1",
|
||||
attributes: {},
|
||||
relationships: { provider: { data: { id: "provider-1" } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === "providers") {
|
||||
return {
|
||||
"provider-1": {
|
||||
id: "provider-1",
|
||||
attributes: { provider: "aws", alias: "prod", uid: "123" },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const input = {
|
||||
data: {
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "uid-1",
|
||||
check_id: "s3_check",
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
check_metadata: {},
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [] },
|
||||
scan: { data: { id: "scan-1" } },
|
||||
},
|
||||
},
|
||||
included: [],
|
||||
};
|
||||
|
||||
// When
|
||||
const [finding] = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then — the partial-scan request needs the id, not only the uid
|
||||
expect(finding.providerId).toBe("provider-1");
|
||||
expect(finding.providerUid).toBe("123");
|
||||
});
|
||||
|
||||
it("should leave the provider id empty when the scan is not included", () => {
|
||||
createDictMock.mockReturnValue({});
|
||||
|
||||
const [finding] = adaptFindingsByResourceResponse({
|
||||
data: {
|
||||
id: "finding-1",
|
||||
attributes: { uid: "uid-1", check_id: "s3_check", status: "FAIL" },
|
||||
relationships: { resources: { data: [] }, scan: { data: null } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(finding.providerId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ResourceDrawerFinding {
|
||||
resourceDetails: string | null;
|
||||
resourceMetadata: Record<string, unknown> | string | null;
|
||||
// Provider
|
||||
providerId: string;
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
@@ -280,6 +281,7 @@ export function adaptFindingsByResourceResponse(
|
||||
| null
|
||||
| undefined) ?? null,
|
||||
// Provider
|
||||
providerId: providerRelId ?? "",
|
||||
providerType: ((providerAttrs.provider as string | undefined) ||
|
||||
"aws") as ProviderType,
|
||||
providerAlias: (providerAttrs.alias as string | undefined) || "",
|
||||
|
||||
@@ -25,6 +25,10 @@ vi.mock("@/lib", () => ({
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
@@ -41,6 +45,7 @@ vi.mock("@/lib/report-download-access", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
createPartialScan,
|
||||
getComplianceCsv,
|
||||
getComplianceOcsf,
|
||||
getCompliancePdfReport,
|
||||
@@ -227,3 +232,64 @@ describe("report downloads for subscription-only tenants", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPartialScan", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 202 }));
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
});
|
||||
|
||||
it("posts the resource uids as a scan of one provider", async () => {
|
||||
// When
|
||||
const result = await createPartialScan({
|
||||
providerId: "provider-1",
|
||||
resourceUids: ["arn:aws:s3:::bucket"],
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/v1/scans",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "scans",
|
||||
attributes: { resource_uids: ["arn:aws:s3:::bucket"] },
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "provider-1" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(handleApiResponseMock).toHaveBeenCalledWith(
|
||||
expect.any(Response),
|
||||
"/scans",
|
||||
);
|
||||
expect(result).toEqual({ data: { id: "scan-1" } });
|
||||
expect(addScanOperationMock).toHaveBeenCalledWith("start", "scan-1");
|
||||
});
|
||||
|
||||
it("refuses more resources than the API accepts without calling it", async () => {
|
||||
// Given — the API caps a partial scan at 10 resources.
|
||||
const resourceUids = Array.from(
|
||||
{ length: 11 },
|
||||
(_, index) => `arn:aws:s3:::bucket-${index}`,
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await createPartialScan({
|
||||
providerId: "provider-1",
|
||||
resourceUids,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: "Select between 1 and 10 resources to re-check",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
import { PARTIAL_SCAN_MAX_RESOURCES } from "@/types/partial-scans";
|
||||
|
||||
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
|
||||
|
||||
@@ -186,6 +187,70 @@ export const scanOnDemand = async (formData: FormData) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Prowler Cloud only: re-check up to PARTIAL_SCAN_MAX_RESOURCES resources of one provider. */
|
||||
export const createPartialScan = async ({
|
||||
providerId,
|
||||
resourceUids,
|
||||
}: {
|
||||
providerId: string;
|
||||
resourceUids: string[];
|
||||
}) => {
|
||||
if (!providerId) {
|
||||
return { error: "Provider ID is required" };
|
||||
}
|
||||
if (
|
||||
resourceUids.length === 0 ||
|
||||
resourceUids.length > PARTIAL_SCAN_MAX_RESOURCES
|
||||
) {
|
||||
return {
|
||||
error: `Select between 1 and ${PARTIAL_SCAN_MAX_RESOURCES} resources to re-check`,
|
||||
};
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
addScanOperation("create", undefined, {
|
||||
provider_id: providerId,
|
||||
partial: true,
|
||||
resource_count: resourceUids.length,
|
||||
});
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/scans`);
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
data: {
|
||||
type: "scans",
|
||||
attributes: { resource_uids: resourceUids },
|
||||
relationships: {
|
||||
provider: {
|
||||
data: {
|
||||
type: "providers",
|
||||
id: providerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse(response, "/scans");
|
||||
if (result?.data?.id) {
|
||||
addScanOperation("start", result.data.id);
|
||||
revalidatePath("/scans");
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
addScanOperation("create");
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleDaily = async (formData: FormData) => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { isCloudMock, hasPermissionMock } = vi.hoisted(() => ({
|
||||
isCloudMock: vi.fn(() => true),
|
||||
hasPermissionMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ hasPermission: hasPermissionMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={onSelect}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { usePartialScanStore } from "@/store/partial-scan/store";
|
||||
|
||||
import {
|
||||
RECHECK_RESOURCE_LABEL,
|
||||
RecheckResourceActionItem,
|
||||
} from "./recheck-resource-action-item";
|
||||
|
||||
const target = {
|
||||
providerId: "provider-1",
|
||||
providerUid: "123456789012",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "bucket",
|
||||
};
|
||||
|
||||
describe("RecheckResourceActionItem", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isCloudMock.mockReturnValue(true);
|
||||
hasPermissionMock.mockReturnValue(true);
|
||||
usePartialScanStore.getState().closePartialScan();
|
||||
});
|
||||
|
||||
it("opens the confirmation with the resource as target", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RecheckResourceActionItem target={target} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL }),
|
||||
);
|
||||
|
||||
expect(usePartialScanStore.getState().activeTarget).toEqual(target);
|
||||
});
|
||||
|
||||
it("is hidden outside Prowler Cloud", () => {
|
||||
isCloudMock.mockReturnValue(false);
|
||||
render(<RecheckResourceActionItem target={target} />);
|
||||
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden without the manage_scans permission", () => {
|
||||
hasPermissionMock.mockReturnValue(false);
|
||||
render(<RecheckResourceActionItem target={target} />);
|
||||
|
||||
expect(hasPermissionMock).toHaveBeenCalledWith("manage_scans");
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden when the row cannot name a resource", () => {
|
||||
render(
|
||||
<RecheckResourceActionItem target={{ ...target, resourceUid: "-" }} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import { ActionDropdownItem } from "@/components/shadcn/dropdown";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
isPartialScanAvailable,
|
||||
isPartialScanTarget,
|
||||
} from "@/lib/partial-scans";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { usePartialScanStore } from "@/store";
|
||||
import type { PartialScanTarget } from "@/types/partial-scans";
|
||||
|
||||
export const RECHECK_RESOURCE_LABEL = "Re-check resource";
|
||||
|
||||
interface RecheckResourceActionItemProps {
|
||||
target: Partial<PartialScanTarget> | null | undefined;
|
||||
}
|
||||
|
||||
/** Prowler Cloud only: opens the partial-scan confirmation for one resource. */
|
||||
export const RecheckResourceActionItem = ({
|
||||
target,
|
||||
}: RecheckResourceActionItemProps) => {
|
||||
const { hasPermission } = useAuth();
|
||||
const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
|
||||
|
||||
const isAvailable = isPartialScanAvailable({
|
||||
cloudEnabled: isCloud(),
|
||||
canManageScans: hasPermission("manage_scans"),
|
||||
});
|
||||
if (!isAvailable || !isPartialScanTarget(target)) return null;
|
||||
|
||||
return (
|
||||
<ActionDropdownItem
|
||||
icon={<RefreshCw className="size-5" />}
|
||||
label={RECHECK_RESOURCE_LABEL}
|
||||
aria-label={RECHECK_RESOURCE_LABEL}
|
||||
onSelect={() => openPartialScan(target)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { RecheckResourceModalMock } = vi.hoisted(() => ({
|
||||
RecheckResourceModalMock: vi.fn(
|
||||
(_props: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
target: unknown;
|
||||
}) => null,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./recheck-resource-modal", () => ({
|
||||
RecheckResourceModal: RecheckResourceModalMock,
|
||||
}));
|
||||
|
||||
import { usePartialScanStore } from "@/store/partial-scan/store";
|
||||
|
||||
import { RecheckResourceModalHost } from "./recheck-resource-modal-host";
|
||||
|
||||
const target = {
|
||||
providerId: "provider-1",
|
||||
providerUid: "123456789012",
|
||||
providerType: "aws",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "bucket",
|
||||
};
|
||||
|
||||
describe("RecheckResourceModalHost", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
usePartialScanStore.getState().closePartialScan();
|
||||
});
|
||||
|
||||
it("renders nothing without an active target", () => {
|
||||
render(<RecheckResourceModalHost />);
|
||||
|
||||
expect(RecheckResourceModalMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mounts the modal for the active target and closes through the store", () => {
|
||||
usePartialScanStore.getState().openPartialScan(target);
|
||||
|
||||
render(<RecheckResourceModalHost />);
|
||||
|
||||
expect(RecheckResourceModalMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isOpen: true, target }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
RecheckResourceModalMock.mock.calls[0][0].onOpenChange(false);
|
||||
|
||||
expect(usePartialScanStore.getState().activeTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { usePartialScanStore } from "@/store";
|
||||
|
||||
import { RecheckResourceModal } from "./recheck-resource-modal";
|
||||
|
||||
// One global modal, like Jira dispatch: it remounts per target so its state
|
||||
// (pending, error) never leaks between resources.
|
||||
export const RecheckResourceModalHost = () => {
|
||||
const activeTarget = usePartialScanStore((state) => state.activeTarget);
|
||||
const closePartialScan = usePartialScanStore(
|
||||
(state) => state.closePartialScan,
|
||||
);
|
||||
|
||||
if (!activeTarget) return null;
|
||||
|
||||
return (
|
||||
<RecheckResourceModal
|
||||
key={activeTarget.resourceUid}
|
||||
isOpen
|
||||
onOpenChange={(open) => !open && closePartialScan()}
|
||||
target={activeTarget}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createPartialScanMock, getProvidersMock, refreshMock, toastMock } =
|
||||
vi.hoisted(() => ({
|
||||
createPartialScanMock: vi.fn(),
|
||||
getProvidersMock: vi.fn(),
|
||||
refreshMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans", () => ({
|
||||
createPartialScan: createPartialScanMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/providers", () => ({
|
||||
getProviders: getProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: refreshMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast", () => ({
|
||||
toast: toastMock,
|
||||
ToastAction: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
// The close button stands in for Escape and backdrop clicks.
|
||||
Modal: ({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { PARTIAL_SCAN_LAUNCH_ERROR } from "@/lib/partial-scans";
|
||||
|
||||
import {
|
||||
PROVIDER_NOT_FOUND_ERROR,
|
||||
RECHECK_RESOURCE_SUBMIT_LABEL,
|
||||
RecheckResourceModal,
|
||||
} from "./recheck-resource-modal";
|
||||
|
||||
const target = {
|
||||
providerId: "provider-1",
|
||||
providerUid: "123456789012",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "bucket",
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: RECHECK_RESOURCE_SUBMIT_LABEL }),
|
||||
);
|
||||
};
|
||||
|
||||
describe("RecheckResourceModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createPartialScanMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
});
|
||||
|
||||
it("launches a partial scan for the one resource and closes", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<RecheckResourceModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
|
||||
await submit();
|
||||
|
||||
expect(createPartialScanMock).toHaveBeenCalledWith({
|
||||
providerId: "provider-1",
|
||||
resourceUids: ["arn:aws:s3:::bucket"],
|
||||
});
|
||||
expect(getProvidersMock).not.toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Re-check launched" }),
|
||||
);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the provider id from its uid and type when the row lacks it", async () => {
|
||||
getProvidersMock.mockResolvedValue({
|
||||
data: [
|
||||
{ id: "aws-1", attributes: { uid: "123456789012", provider: "aws" } },
|
||||
],
|
||||
});
|
||||
const { providerId: _omitted, ...rowTarget } = target;
|
||||
render(
|
||||
<RecheckResourceModal isOpen onOpenChange={vi.fn()} target={rowTarget} />,
|
||||
);
|
||||
|
||||
await submit();
|
||||
|
||||
expect(getProvidersMock).toHaveBeenCalledWith({
|
||||
filters: { "filter[uid]": "123456789012", "filter[provider]": "aws" },
|
||||
});
|
||||
expect(createPartialScanMock).toHaveBeenCalledWith({
|
||||
providerId: "aws-1",
|
||||
resourceUids: ["arn:aws:s3:::bucket"],
|
||||
});
|
||||
});
|
||||
|
||||
it("explains when the provider cannot be found and launches nothing", async () => {
|
||||
getProvidersMock.mockResolvedValue({ data: [] });
|
||||
const { providerId: _omitted, ...rowTarget } = target;
|
||||
render(
|
||||
<RecheckResourceModal isOpen onOpenChange={vi.fn()} target={rowTarget} />,
|
||||
);
|
||||
|
||||
await submit();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
PROVIDER_NOT_FOUND_ERROR,
|
||||
);
|
||||
expect(createPartialScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the modal open and shows the API reason when the re-check is refused", async () => {
|
||||
// The 409 detail already tells the user what to do, so it is shown verbatim.
|
||||
createPartialScanMock.mockResolvedValue({
|
||||
error:
|
||||
"A scan is already running for this provider. Re-check these resources once it finishes.",
|
||||
status: 409,
|
||||
});
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<RecheckResourceModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
|
||||
await submit();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"A scan is already running for this provider.",
|
||||
);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: RECHECK_RESOURCE_SUBMIT_LABEL }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a response without a scan id as a failed launch", async () => {
|
||||
// An empty 2xx becomes { success: true } and there is no scan to follow.
|
||||
createPartialScanMock.mockResolvedValue({ success: true, status: 202 });
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<RecheckResourceModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
|
||||
await submit();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
PARTIAL_SCAN_LAUNCH_ERROR,
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a dismiss while the request is in flight", async () => {
|
||||
let resolveLaunch!: (value: unknown) => void;
|
||||
createPartialScanMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLaunch = resolve;
|
||||
}),
|
||||
);
|
||||
const onOpenChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<RecheckResourceModal
|
||||
isOpen
|
||||
onOpenChange={onOpenChange}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: RECHECK_RESOURCE_SUBMIT_LABEL }),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
|
||||
resolveLaunch({ data: { id: "scan-1" } });
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { createPartialScan } from "@/actions/scans";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import {
|
||||
findProviderIdForTarget,
|
||||
getPartialScanErrorMessage,
|
||||
PARTIAL_SCAN_LAUNCH_ERROR,
|
||||
} from "@/lib/partial-scans";
|
||||
import type { PartialScanTarget } from "@/types/partial-scans";
|
||||
|
||||
export const RECHECK_RESOURCE_SUBMIT_LABEL = "Re-check resource";
|
||||
export const PROVIDER_NOT_FOUND_ERROR =
|
||||
"We couldn't find the provider of this resource. Refresh the page and try again.";
|
||||
|
||||
interface RecheckResourceModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
target: PartialScanTarget;
|
||||
}
|
||||
|
||||
const hasDisplayName = (name: string) => name.trim() !== "" && name !== "-";
|
||||
|
||||
export function RecheckResourceModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
target,
|
||||
}: RecheckResourceModalProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const resourceLabel = hasDisplayName(target.resourceName)
|
||||
? target.resourceName
|
||||
: target.resourceUid;
|
||||
|
||||
// Drill-down rows only know the provider by uid + type; the API needs its id.
|
||||
const resolveProviderId = async () => {
|
||||
if (target.providerId) return target.providerId;
|
||||
|
||||
const response = await getProviders({
|
||||
filters: {
|
||||
"filter[uid]": target.providerUid,
|
||||
"filter[provider]": target.providerType,
|
||||
},
|
||||
});
|
||||
|
||||
return findProviderIdForTarget(response?.data ?? [], target);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isPending) return;
|
||||
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const providerId = await resolveProviderId();
|
||||
if (!providerId) {
|
||||
setError(PROVIDER_NOT_FOUND_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createPartialScan({
|
||||
providerId,
|
||||
resourceUids: [target.resourceUid],
|
||||
});
|
||||
const errorMessage = getPartialScanErrorMessage(result);
|
||||
if (errorMessage) {
|
||||
setError(errorMessage);
|
||||
return;
|
||||
}
|
||||
// An empty 2xx has no scan to follow, so it is not a launch.
|
||||
if (!result?.data?.id) {
|
||||
setError(PARTIAL_SCAN_LAUNCH_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Re-check launched",
|
||||
description: `Only ${resourceLabel} is being re-checked. Its findings update once the scan completes.`,
|
||||
action: (
|
||||
<ToastAction altText="View scan in progress" asChild>
|
||||
<Link href="/scans?tab=active">View scan</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(PARTIAL_SCAN_LAUNCH_ERROR);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
// Escape and backdrop must not unmount the modal mid-request, or the
|
||||
// error would land on an unmounted component.
|
||||
onOpenChange={(open) => {
|
||||
if (!open && isPending) return;
|
||||
onOpenChange(open);
|
||||
}}
|
||||
title="Re-check this resource"
|
||||
description="Run a partial scan on a single resource instead of the whole provider."
|
||||
size="lg"
|
||||
>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
|
||||
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
|
||||
Resource
|
||||
</p>
|
||||
<p className="text-text-neutral-primary mt-2 text-sm font-semibold break-all">
|
||||
{resourceLabel}
|
||||
</p>
|
||||
{resourceLabel !== target.resourceUid && (
|
||||
<p className="text-text-neutral-tertiary mt-1 text-xs break-all">
|
||||
{target.resourceUid}
|
||||
</p>
|
||||
)}
|
||||
{target.providerAlias && (
|
||||
<p className="text-text-neutral-secondary mt-2 text-xs">
|
||||
Provider:{" "}
|
||||
<span className="text-text-neutral-primary">
|
||||
{target.providerAlias}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-text-neutral-secondary space-y-2 text-sm">
|
||||
<p>
|
||||
Only the checks that last reported on this resource run again. Its
|
||||
findings update when the scan completes; every other resource keeps
|
||||
the results of the latest full scan.
|
||||
</p>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Overviews and compliance do not change until the next full scan. A
|
||||
re-check is refused while the provider has a scan running or queued.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-text-error-primary text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="lg" disabled={isPending}>
|
||||
{isPending ? "Launching..." : RECHECK_RESOURCE_SUBMIT_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -189,6 +189,14 @@ vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
// The re-check menu item reads the session for manage_scans; grant it here.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
permissions: { manage_scans: true },
|
||||
hasPermission: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./lighthouse-skills-launch", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("./lighthouse-skills-launch")>();
|
||||
@@ -208,6 +216,7 @@ vi.mock("./notification-indicator", () => ({
|
||||
}));
|
||||
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { usePartialScanStore } from "@/store/partial-scan/store";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
@@ -320,6 +329,34 @@ describe("column-finding-resources", () => {
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("offers a Cloud re-check identified by provider uid and type", async () => {
|
||||
// Given — drill-down rows carry no provider id, only its uid and type
|
||||
const user = userEvent.setup();
|
||||
isCloudMock.mockReturnValue(true);
|
||||
usePartialScanStore.getState().closePartialScan();
|
||||
renderResourceActionsCell();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Re-check resource" }));
|
||||
|
||||
// Then
|
||||
expect(usePartialScanStore.getState().activeTarget).toEqual({
|
||||
providerUid: "123456789",
|
||||
providerType: "aws",
|
||||
providerAlias: "production",
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
resourceName: "my-bucket",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the re-check outside Prowler Cloud", () => {
|
||||
renderResourceActionsCell();
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Re-check resource" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the finding drawer and launches a row skill with full context", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useContext, useState } from "react";
|
||||
|
||||
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item";
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -199,6 +200,15 @@ const ResourceRowActions = ({
|
||||
})}
|
||||
payload={jiraPayload}
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={{
|
||||
providerUid: resource.providerUid,
|
||||
providerType: resource.providerType,
|
||||
providerAlias: resource.providerAlias,
|
||||
resourceUid: resource.resourceUid,
|
||||
resourceName: resource.resourceName,
|
||||
}}
|
||||
/>
|
||||
{isCloud() && (
|
||||
<LighthouseSkillsSubmenu
|
||||
onLaunch={(skill) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { usePartialScanStore } from "@/store/partial-scan/store";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
@@ -44,6 +45,14 @@ vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
// The re-check menu item reads the session for manage_scans; grant it here.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
permissions: { manage_scans: true },
|
||||
hasPermission: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./lighthouse-skills-launch", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("./lighthouse-skills-launch")>();
|
||||
@@ -170,6 +179,58 @@ describe("DataTableRowActions", () => {
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("offers a Cloud re-check of the row's resource with its provider id", async () => {
|
||||
// Given — a flat finding row expanded with its resource and provider
|
||||
const user = userEvent.setup();
|
||||
isCloudMock.mockReturnValue(true);
|
||||
usePartialScanStore.getState().closePartialScan();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
relationships: {
|
||||
resource: {
|
||||
attributes: { name: "my-bucket", uid: "arn:aws:s3:::my-bucket" },
|
||||
},
|
||||
provider: {
|
||||
id: "provider-1",
|
||||
attributes: { alias: "prod", provider: "aws", uid: "123" },
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Re-check resource" }));
|
||||
|
||||
// Then
|
||||
expect(usePartialScanStore.getState().activeTarget).toEqual({
|
||||
providerId: "provider-1",
|
||||
providerUid: "123",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
resourceName: "my-bucket",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a re-check outside Prowler Cloud", () => {
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
relationships: {
|
||||
resource: { attributes: { uid: "arn:aws:s3:::my-bucket" } },
|
||||
provider: { id: "provider-1", attributes: { provider: "aws" } },
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Re-check resource" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("launches a Lighthouse skill from the row submenu with finding context", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -212,6 +273,10 @@ describe("DataTableRowActions", () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Lighthouse Skills")).not.toBeInTheDocument();
|
||||
// A group spans many resources, so a single-resource re-check is not offered.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Re-check resource" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Triage Decision" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useContext, useState } from "react";
|
||||
|
||||
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
@@ -53,12 +54,17 @@ export interface FindingRowData {
|
||||
resource?: {
|
||||
attributes?: {
|
||||
name?: string;
|
||||
uid?: string;
|
||||
};
|
||||
};
|
||||
provider?: {
|
||||
// Expanded included providers carry their id at the top level.
|
||||
id?: string;
|
||||
data?: { id?: string };
|
||||
attributes?: {
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
uid?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -246,6 +252,20 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Partial scans re-check one resource, so group rows never offer them.
|
||||
const recheckTarget = isGroup
|
||||
? null
|
||||
: {
|
||||
providerId:
|
||||
finding.relationships?.provider?.id ??
|
||||
finding.relationships?.provider?.data?.id,
|
||||
providerUid: finding.relationships?.provider?.attributes?.uid,
|
||||
providerType: finding.relationships?.provider?.attributes?.provider,
|
||||
providerAlias: finding.relationships?.provider?.attributes?.alias,
|
||||
resourceUid: finding.relationships?.resource?.attributes?.uid,
|
||||
resourceName: finding.relationships?.resource?.attributes?.name,
|
||||
};
|
||||
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
// Skills are finding-level only: group rows carry check ids, not finding
|
||||
@@ -300,6 +320,7 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<JiraDispatchActionItem label={jiraLabel} payload={jiraPayload} />
|
||||
<RecheckResourceActionItem target={recheckTarget} />
|
||||
{isCloud() && !isGroup && (
|
||||
<LighthouseSkillsSubmenu
|
||||
onLaunch={handleLaunchSkill}
|
||||
|
||||
+55
-1
@@ -9,7 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks for components that pull in next-auth transitively
|
||||
@@ -406,6 +406,14 @@ vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: mockIsCloud,
|
||||
}));
|
||||
|
||||
// The re-check menu item reads the session for manage_scans; grant it here.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
permissions: { manage_scans: true },
|
||||
hasPermission: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_lib/panel-chat-store", () => ({
|
||||
requestPanelChatMessage: mockRequestPanelChatMessage,
|
||||
requestPanelSkillLaunch: mockRequestPanelSkillLaunch,
|
||||
@@ -543,6 +551,7 @@ vi.mock("../../muted", () => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { usePartialScanStore } from "@/store/partial-scan/store";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
@@ -655,6 +664,7 @@ const mockFinding: ResourceDrawerFinding = {
|
||||
resourceGroup: "default",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123456789",
|
||||
@@ -2241,3 +2251,47 @@ describe("ResourceDetailDrawerContent — Metadata tab", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — re-check resource", () => {
|
||||
beforeEach(() => {
|
||||
usePartialScanStore.getState().closePartialScan();
|
||||
});
|
||||
|
||||
it("should offer a re-check of the current resource with its provider id", async () => {
|
||||
// Given — the drawer finding was fetched with scan.provider, so it knows the id
|
||||
const user = userEvent.setup();
|
||||
mockIsCloud.mockReturnValue(true);
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
within(screen.getByRole("menu", { name: "Resource actions" })).getByRole(
|
||||
"button",
|
||||
{ name: "Re-check resource" },
|
||||
),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(usePartialScanStore.getState().activeTarget).toEqual({
|
||||
providerId: "provider-1",
|
||||
providerUid: "123456789",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "my-bucket",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+11
@@ -27,6 +27,7 @@ import {
|
||||
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item";
|
||||
import { getComplianceIcon } from "@/components/icons";
|
||||
import {
|
||||
Badge,
|
||||
@@ -784,6 +785,16 @@ export function ResourceDetailDrawerContent({
|
||||
label={buildJiraActionLabel({ findingCount: 1 })}
|
||||
payload={jiraPayload}
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={{
|
||||
providerId: f.providerId,
|
||||
providerUid,
|
||||
providerType,
|
||||
providerAlias,
|
||||
resourceUid,
|
||||
resourceName,
|
||||
}}
|
||||
/>
|
||||
{externalResourceTarget && (
|
||||
<ActionDropdownItem
|
||||
icon={<ExternalLink className="size-5" />}
|
||||
|
||||
@@ -178,6 +178,7 @@ function drawerFinding(
|
||||
resourceGroup: "storage",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerAlias: "Production",
|
||||
providerUid: "123456789012",
|
||||
|
||||
@@ -110,6 +110,7 @@ function makeDrawerFinding(
|
||||
resourceGroup: "default",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123",
|
||||
|
||||
@@ -15,6 +15,12 @@ vi.mock("@/components/findings/jira-dispatch-modal-host", () => ({
|
||||
JiraDispatchModalHost: () => <div data-testid="jira-dispatch-modal-host" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/recheck-resource-modal-host", () => ({
|
||||
RecheckResourceModalHost: () => (
|
||||
<div data-testid="recheck-resource-modal-host" />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("MainLayout", () => {
|
||||
it("mounts the shared Cloud upgrade modal with page content", () => {
|
||||
render(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePathname } from "next/navigation";
|
||||
import { type ReactNode, Suspense } from "react";
|
||||
|
||||
import { JiraDispatchModalHost } from "@/components/findings/jira-dispatch-modal-host";
|
||||
import { RecheckResourceModalHost } from "@/components/findings/recheck-resource-modal-host";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
@@ -40,6 +41,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
<AppSidebar />
|
||||
<CloudUpgradeModal />
|
||||
<JiraDispatchModalHost />
|
||||
<RecheckResourceModalHost />
|
||||
<main
|
||||
// @container: <main> is the reference for the app's (container-query)
|
||||
// breakpoints, so pushing it with the side panel re-evaluates them.
|
||||
|
||||
@@ -27,12 +27,25 @@ export function ScanInfoCell({ scan }: { scan: ScanProps }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[240px] min-w-0">
|
||||
<div className="flex max-w-[240px] min-w-0 items-center gap-2">
|
||||
<EntityInfo
|
||||
entityAlias={getScanAlias(scan)}
|
||||
entityId={scan.id}
|
||||
idLabel="ID"
|
||||
/>
|
||||
{scan.attributes.is_partial && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="tag" tabIndex={0}>
|
||||
Partial
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Re-checked a few resources. Overviews still reflect the latest full
|
||||
scan.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,17 @@ import {
|
||||
|
||||
vi.mock("@/components/shadcn", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
Badge: ({
|
||||
children,
|
||||
tabIndex,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tabIndex?: number;
|
||||
}) => <span tabIndex={tabIndex}>{children}</span>,
|
||||
Progress: () => <div />,
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null,
|
||||
StackedCell: ({
|
||||
primary,
|
||||
secondary,
|
||||
@@ -188,6 +197,25 @@ describe("getScanJobsColumns", () => {
|
||||
expect(screen.getByText("ID: scan-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a partial scan next to its alias", () => {
|
||||
// Prowler Cloud exposes is_partial for re-checks of a few resources.
|
||||
const scan = makeCompletedScan();
|
||||
renderCell("scanInfo", {
|
||||
...scan,
|
||||
attributes: { ...scan.attributes, is_partial: true },
|
||||
});
|
||||
|
||||
expect(screen.getByText("Production scan")).toBeInTheDocument();
|
||||
// Focusable so keyboard users can reach the tooltip.
|
||||
expect(screen.getByText("Partial")).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
it("shows no partial label on a full scan", () => {
|
||||
renderCell("scanInfo", makeCompletedScan());
|
||||
|
||||
expect(screen.queryByText("Partial")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the completed duration column", () => {
|
||||
renderCell("duration", makeCompletedScan());
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
findProviderIdForTarget,
|
||||
getPartialScanErrorMessage,
|
||||
isPartialScanAvailable,
|
||||
isPartialScanTarget,
|
||||
PARTIAL_SCAN_LAUNCH_ERROR,
|
||||
} from "./partial-scans";
|
||||
|
||||
describe("isPartialScanAvailable", () => {
|
||||
it("needs both Prowler Cloud and the manage_scans permission", () => {
|
||||
expect(
|
||||
isPartialScanAvailable({ cloudEnabled: true, canManageScans: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPartialScanAvailable({ cloudEnabled: false, canManageScans: true }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPartialScanAvailable({ cloudEnabled: true, canManageScans: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPartialScanTarget", () => {
|
||||
it("accepts a resource uid with a provider id", () => {
|
||||
expect(
|
||||
isPartialScanTarget({
|
||||
providerId: "provider-1",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "bucket",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a resource uid with a provider uid and type", () => {
|
||||
expect(
|
||||
isPartialScanTarget({
|
||||
providerUid: "123456789012",
|
||||
providerType: "aws",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "bucket",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects placeholder or missing identifiers", () => {
|
||||
// Adapters fill unknown values with "-", which is not a real uid.
|
||||
expect(
|
||||
isPartialScanTarget({
|
||||
providerId: "provider-1",
|
||||
resourceUid: "-",
|
||||
resourceName: "bucket",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPartialScanTarget({
|
||||
providerUid: "",
|
||||
providerType: "aws",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isPartialScanTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findProviderIdForTarget", () => {
|
||||
const providers = [
|
||||
{ id: "aws-1", attributes: { uid: "123456789012", provider: "aws" } },
|
||||
{ id: "gcp-1", attributes: { uid: "123456789012", provider: "gcp" } },
|
||||
] as never[];
|
||||
|
||||
it("matches on uid and provider type together", () => {
|
||||
expect(
|
||||
findProviderIdForTarget(providers, {
|
||||
providerUid: "123456789012",
|
||||
providerType: "gcp",
|
||||
}),
|
||||
).toBe("gcp-1");
|
||||
});
|
||||
|
||||
it("returns undefined when nothing matches", () => {
|
||||
expect(
|
||||
findProviderIdForTarget(providers, {
|
||||
providerUid: "000000000000",
|
||||
providerType: "aws",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPartialScanErrorMessage", () => {
|
||||
it("returns null for a created scan", () => {
|
||||
expect(getPartialScanErrorMessage({ data: { id: "scan-1" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces the API detail for a refused re-check", () => {
|
||||
expect(
|
||||
getPartialScanErrorMessage({
|
||||
error: "A scan is already running for this provider.",
|
||||
status: 409,
|
||||
}),
|
||||
).toBe("A scan is already running for this provider.");
|
||||
});
|
||||
|
||||
it("falls back to a generic message when the error carries no detail", () => {
|
||||
expect(getPartialScanErrorMessage({ status: 500 })).toBe(
|
||||
PARTIAL_SCAN_LAUNCH_ERROR,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
type ActionErrorResult,
|
||||
getActionErrorMessage,
|
||||
hasActionError,
|
||||
} from "@/lib/action-errors";
|
||||
import type { PartialScanTarget } from "@/types/partial-scans";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
export const PARTIAL_SCAN_LAUNCH_ERROR =
|
||||
"The re-check could not be launched. Please try again.";
|
||||
|
||||
interface PartialScanAvailability {
|
||||
cloudEnabled: boolean;
|
||||
canManageScans: boolean;
|
||||
}
|
||||
|
||||
/** Partial scans are a Cloud feature that needs the manage_scans permission. */
|
||||
export const isPartialScanAvailable = ({
|
||||
cloudEnabled,
|
||||
canManageScans,
|
||||
}: PartialScanAvailability): boolean => cloudEnabled && canManageScans;
|
||||
|
||||
const isMeaningful = (value: string | undefined): value is string =>
|
||||
typeof value === "string" && value.trim() !== "" && value !== "-";
|
||||
|
||||
/** A target needs a real resource uid and a way to identify its provider. */
|
||||
export const isPartialScanTarget = (
|
||||
target: Partial<PartialScanTarget> | null | undefined,
|
||||
): target is PartialScanTarget => {
|
||||
if (!target || !isMeaningful(target.resourceUid)) return false;
|
||||
if (isMeaningful(target.providerId)) return true;
|
||||
return isMeaningful(target.providerUid) && isMeaningful(target.providerType);
|
||||
};
|
||||
|
||||
/** Provider uid is unique per provider type, so both together pick one row. */
|
||||
export const findProviderIdForTarget = (
|
||||
providers: Pick<ProviderProps, "id" | "attributes">[],
|
||||
target: Pick<PartialScanTarget, "providerUid" | "providerType">,
|
||||
): string | undefined =>
|
||||
providers.find(
|
||||
(provider) =>
|
||||
provider.attributes.uid === target.providerUid &&
|
||||
provider.attributes.provider === target.providerType,
|
||||
)?.id;
|
||||
|
||||
export const getPartialScanErrorMessage = (
|
||||
result: (ActionErrorResult & { data?: unknown }) | null | undefined,
|
||||
): string | null =>
|
||||
hasActionError(result)
|
||||
? getActionErrorMessage(result, { fallback: PARTIAL_SCAN_LAUNCH_ERROR })
|
||||
: null;
|
||||
@@ -2,6 +2,7 @@ export * from "./cloud-upgrade/store";
|
||||
export * from "./compliance/store";
|
||||
export * from "./jira-dispatch/store";
|
||||
export * from "./organizations/store";
|
||||
export * from "./partial-scan/store";
|
||||
export * from "./provider-wizard/store";
|
||||
export * from "./scans/store";
|
||||
export * from "./ui/store";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { PartialScanTarget } from "@/types/partial-scans";
|
||||
|
||||
interface PartialScanStoreState {
|
||||
activeTarget: PartialScanTarget | null;
|
||||
openPartialScan: (target: PartialScanTarget) => void;
|
||||
closePartialScan: () => void;
|
||||
}
|
||||
|
||||
// Menu items live inside dropdowns that unmount on select, so the confirmation
|
||||
// modal is hosted once globally and driven through this store.
|
||||
export const usePartialScanStore = create<PartialScanStoreState>((set) => ({
|
||||
activeTarget: null,
|
||||
openPartialScan: (activeTarget) => set({ activeTarget }),
|
||||
closePartialScan: () => set({ activeTarget: null }),
|
||||
}));
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ProviderType } from "./providers";
|
||||
|
||||
/** Mirrors `PARTIAL_SCAN_MAX_RESOURCES` in the Cloud API. */
|
||||
export const PARTIAL_SCAN_MAX_RESOURCES = 10;
|
||||
|
||||
/** One resource to re-check. Prowler Cloud only. */
|
||||
export interface PartialScanTarget {
|
||||
/** Provider UUID. Resolved from `providerUid` + `providerType` when absent. */
|
||||
providerId?: string;
|
||||
providerUid: string;
|
||||
providerType: ProviderType;
|
||||
providerAlias?: string;
|
||||
resourceUid: string;
|
||||
resourceName: string;
|
||||
}
|
||||
@@ -62,6 +62,8 @@ export interface ScanAttributes {
|
||||
completed_at: string | null;
|
||||
scheduled_at: string | null;
|
||||
next_scan_at: string | null;
|
||||
/** Prowler Cloud only: true when the scan re-checked a few resources. */
|
||||
is_partial?: boolean;
|
||||
}
|
||||
|
||||
export interface ScanRelationships {
|
||||
|
||||
Reference in New Issue
Block a user