feat(ui): re-check a resource with a partial scan from the findings actions (#12879)

This commit is contained in:
Alejandro Bailo
2026-09-24 13:54:15 +02:00
committed by GitHub
parent 576433d85d
commit 4dbc3c7e74
28 changed files with 1262 additions and 3 deletions
@@ -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) || "",
+66
View File
@@ -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",
});
});
});
+65
View File
@@ -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}
@@ -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",
});
});
});
@@ -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());
+111
View File
@@ -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,
);
});
});
+51
View File
@@ -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;
+1
View File
@@ -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";
+17
View File
@@ -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 }),
}));
+15
View File
@@ -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;
}
+2
View File
@@ -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 {