mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
feat(ui): show the compliance an issue puts at risk (#13153)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
1 parent
1ae3856de0
commit
63b8c8969c
7 files changed
+505
-92
No files matched your search
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons";
|
||||
import { Badge, Button } from "@/components/shadcn";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
|
||||
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
|
||||
* version is part of the label, not decoration. */
|
||||
export function complianceFrameworkLabel(
|
||||
framework: FindingComplianceFramework,
|
||||
): string {
|
||||
const name = framework.framework || framework.name;
|
||||
return framework.version ? `${name} ${framework.version}` : name;
|
||||
}
|
||||
|
||||
interface ComplianceFrameworkChipProps {
|
||||
framework: FindingComplianceFramework;
|
||||
isNavigable: boolean;
|
||||
onOpen: (framework: FindingComplianceFramework) => void;
|
||||
}
|
||||
|
||||
export function ComplianceFrameworkChip({
|
||||
framework,
|
||||
isNavigable,
|
||||
onOpen,
|
||||
}: ComplianceFrameworkChipProps) {
|
||||
const icon = getComplianceIcon(framework.complianceId);
|
||||
const label = complianceFrameworkLabel(framework);
|
||||
const content = icon ? (
|
||||
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={label}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={icon ? "bare" : "outline"}
|
||||
size={icon ? "icon-xs" : "sm"}
|
||||
aria-label={`Open ${label} compliance details`}
|
||||
onClick={() => onOpen(framework)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
) : icon ? (
|
||||
content
|
||||
) : (
|
||||
<Badge variant="tag" size="sm" aria-label={label}>
|
||||
{content}
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildComplianceDetailHref({
|
||||
complianceId,
|
||||
framework,
|
||||
version,
|
||||
scanId,
|
||||
regionFilter,
|
||||
}: {
|
||||
complianceId: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
scanId: string;
|
||||
regionFilter: string | null;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("complianceId", complianceId);
|
||||
if (version) {
|
||||
params.set("version", version);
|
||||
}
|
||||
params.set("scanId", scanId);
|
||||
|
||||
if (regionFilter) {
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
|
||||
}
|
||||
+4
-91
@@ -9,7 +9,6 @@ import {
|
||||
VolumeOff,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -24,12 +23,15 @@ import {
|
||||
requestPanelChatMessage,
|
||||
requestPanelSkillLaunch,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/panel-chat-store";
|
||||
import {
|
||||
buildComplianceDetailHref,
|
||||
ComplianceFrameworkChip,
|
||||
} from "@/components/compliance/compliance-framework-chip";
|
||||
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 { RecheckResourceIconButton } from "@/components/findings/recheck-resource-icon-button";
|
||||
import { getComplianceIcon } from "@/components/icons";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -177,68 +179,6 @@ function renderRemediationCodeBlock({
|
||||
);
|
||||
}
|
||||
|
||||
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
|
||||
* version is part of the label, not decoration. */
|
||||
function complianceFrameworkLabel(
|
||||
framework: FindingComplianceFramework,
|
||||
): string {
|
||||
const name = framework.framework || framework.name;
|
||||
return framework.version ? `${name} ${framework.version}` : name;
|
||||
}
|
||||
|
||||
interface ComplianceFrameworkChipProps {
|
||||
framework: FindingComplianceFramework;
|
||||
isNavigable: boolean;
|
||||
onOpen: (framework: FindingComplianceFramework) => void;
|
||||
}
|
||||
|
||||
function ComplianceFrameworkChip({
|
||||
framework,
|
||||
isNavigable,
|
||||
onOpen,
|
||||
}: ComplianceFrameworkChipProps) {
|
||||
const icon = getComplianceIcon(framework.complianceId);
|
||||
const label = complianceFrameworkLabel(framework);
|
||||
const content = icon ? (
|
||||
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={label}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={icon ? "bare" : "outline"}
|
||||
size={icon ? "icon-xs" : "sm"}
|
||||
aria-label={`Open ${label} compliance details`}
|
||||
onClick={() => onOpen(framework)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
) : icon ? (
|
||||
content
|
||||
) : (
|
||||
<Badge variant="tag" size="sm" aria-label={label}>
|
||||
{content}
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
if (!scanFilterValue) {
|
||||
return [];
|
||||
@@ -250,33 +190,6 @@ function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildComplianceDetailHref({
|
||||
complianceId,
|
||||
framework,
|
||||
version,
|
||||
scanId,
|
||||
regionFilter,
|
||||
}: {
|
||||
complianceId: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
scanId: string;
|
||||
regionFilter: string | null;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("complianceId", complianceId);
|
||||
if (version) {
|
||||
params.set("version", version);
|
||||
}
|
||||
params.set("scanId", scanId);
|
||||
|
||||
if (regionFilter) {
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildResourceDetailHref(resourceId: string): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("resourceId", resourceId);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TooltipProvider } from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
type FindingComplianceFramework,
|
||||
WATCHLIST_SCOPE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { IssueFinding } from "@/types/issues";
|
||||
|
||||
import { COMPLIANCE_LOOKUP_LIMIT, IssueCompliance } from "./issue-compliance";
|
||||
|
||||
const { getFrameworksMock } = vi.hoisted(() => ({
|
||||
getFrameworksMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings/finding-compliance-frameworks", () => ({
|
||||
getFindingComplianceFrameworks: getFrameworksMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
const framework = (
|
||||
complianceId: string,
|
||||
name: string,
|
||||
inWatchlist = false,
|
||||
): FindingComplianceFramework => ({
|
||||
id: complianceId,
|
||||
complianceId,
|
||||
providerType: "aws",
|
||||
scope: WATCHLIST_SCOPE.PROVIDER,
|
||||
framework: name,
|
||||
name,
|
||||
version: "",
|
||||
inWatchlist,
|
||||
});
|
||||
|
||||
const finding = (id: string, checkId: string): IssueFinding => ({
|
||||
id,
|
||||
uid: id,
|
||||
checkId,
|
||||
checkTitle: checkId,
|
||||
severity: "high",
|
||||
triageStatus: "open",
|
||||
resourceUid: `r-${id}`,
|
||||
});
|
||||
|
||||
const renderCompliance = (
|
||||
findings: IssueFinding[],
|
||||
scanId?: string,
|
||||
truncated?: boolean,
|
||||
) =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<IssueCompliance
|
||||
findings={findings}
|
||||
scanId={scanId}
|
||||
truncated={truncated}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const INCOMPLETE =
|
||||
"Not every finding could be checked, so this list may be incomplete.";
|
||||
|
||||
describe("IssueCompliance", () => {
|
||||
beforeEach(() => {
|
||||
getFrameworksMock.mockResolvedValue({ frameworks: [], unavailable: false });
|
||||
});
|
||||
|
||||
it("looks up one finding per check, up to the limit", async () => {
|
||||
const findings = [
|
||||
finding("f1", "check-a"),
|
||||
finding("f2", "check-a"),
|
||||
...Array.from({ length: COMPLIANCE_LOOKUP_LIMIT + 2 }, (_, i) =>
|
||||
finding(`g${i}`, `check-${i}`),
|
||||
),
|
||||
];
|
||||
renderCompliance(findings);
|
||||
|
||||
expect(await screen.findByText(/map to a framework/)).toBeInTheDocument();
|
||||
expect(getFrameworksMock).toHaveBeenCalledTimes(COMPLIANCE_LOOKUP_LIMIT);
|
||||
expect(getFrameworksMock).toHaveBeenCalledWith("f2");
|
||||
});
|
||||
|
||||
it("lists each framework once, the watchlist first, and opens it in the scan", async () => {
|
||||
// Given
|
||||
getFrameworksMock
|
||||
.mockResolvedValueOnce({
|
||||
frameworks: [
|
||||
framework("cis_aws", "CIS"),
|
||||
framework("soc2", "SOC2", true),
|
||||
],
|
||||
unavailable: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
frameworks: [framework("cis_aws", "CIS")],
|
||||
unavailable: false,
|
||||
});
|
||||
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
renderCompliance(
|
||||
[finding("f1", "check-a"), finding("f2", "check-b")],
|
||||
"scan-1",
|
||||
);
|
||||
|
||||
// When
|
||||
const buttons = await screen.findAllByRole("button", { name: /^Open / });
|
||||
await userEvent.click(buttons[0]);
|
||||
|
||||
// Then
|
||||
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
|
||||
"Open SOC2 compliance details",
|
||||
"Open CIS compliance details",
|
||||
]);
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
"/compliance/SOC2?complianceId=soc2&scanId=scan-1",
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when no framework could be loaded, and looks again on retry", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
getFrameworksMock.mockResolvedValueOnce({
|
||||
frameworks: [],
|
||||
unavailable: true,
|
||||
});
|
||||
renderCompliance([finding("f1", "check-a")]);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load the frameworks for this issue.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText(/behind this issue map to a framework/),
|
||||
).toBeInTheDocument();
|
||||
expect(getFrameworksMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not claim no framework applies when one lookup failed", async () => {
|
||||
// Given
|
||||
getFrameworksMock
|
||||
.mockResolvedValueOnce({ frameworks: [], unavailable: false })
|
||||
.mockResolvedValueOnce({ frameworks: [], unavailable: true });
|
||||
|
||||
// When
|
||||
renderCompliance([finding("f1", "check-a"), finding("f2", "check-b")]);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText(INCOMPLETE)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/behind this issue map to a framework/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops loading when a lookup is rejected", async () => {
|
||||
getFrameworksMock.mockRejectedValue(new Error("Network error"));
|
||||
renderCompliance([finding("f1", "check-a")]);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Could not load the frameworks for this issue."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags the list as incomplete when the findings are a capped preview", async () => {
|
||||
getFrameworksMock.mockResolvedValue({
|
||||
frameworks: [framework("cis_aws", "CIS")],
|
||||
unavailable: false,
|
||||
});
|
||||
renderCompliance([finding("f1", "check-a")], "scan-1", true);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: "Open CIS compliance details",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(INCOMPLETE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getFindingComplianceFrameworks } from "@/actions/findings/finding-compliance-frameworks";
|
||||
import {
|
||||
buildComplianceDetailHref,
|
||||
ComplianceFrameworkChip,
|
||||
complianceFrameworkLabel,
|
||||
} from "@/components/compliance/compliance-framework-chip";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import { IssueFinding } from "@/types/issues";
|
||||
|
||||
// shortcut: frameworks are looked up for at most this many distinct checks,
|
||||
// one request each; add an issue-level endpoint if issues fold more checks.
|
||||
export const COMPLIANCE_LOOKUP_LIMIT = 6;
|
||||
|
||||
type ComplianceState =
|
||||
| { status: "loading" }
|
||||
| { status: "unavailable" }
|
||||
| {
|
||||
status: "loaded";
|
||||
frameworks: FindingComplianceFramework[];
|
||||
/** Some lookups failed, so frameworks may be missing. */
|
||||
partial: boolean;
|
||||
};
|
||||
|
||||
interface IssueComplianceProps {
|
||||
findings: IssueFinding[];
|
||||
scanId?: string;
|
||||
/** `findings` is a capped preview of a larger issue. */
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frameworks the issue puts at risk, from Prowler's own per-finding
|
||||
* mapping. Frameworks belong to the check, so one finding per check answers
|
||||
* for all of them.
|
||||
*/
|
||||
export const IssueCompliance = ({
|
||||
findings,
|
||||
scanId,
|
||||
truncated = false,
|
||||
}: IssueComplianceProps) => {
|
||||
const [state, setState] = useState<ComplianceState>({ status: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const checks = new Map(findings.map((f) => [f.checkId, f.id]));
|
||||
const representatives = Array.from(checks.values()).slice(
|
||||
0,
|
||||
COMPLIANCE_LOOKUP_LIMIT,
|
||||
);
|
||||
const key = representatives.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all(
|
||||
key
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((id) => getFindingComplianceFrameworks(id)),
|
||||
)
|
||||
.then((results) => {
|
||||
if (cancelled) return;
|
||||
if (results.length > 0 && results.every((r) => r.unavailable)) {
|
||||
setState({ status: "unavailable" });
|
||||
return;
|
||||
}
|
||||
const byId = new Map(
|
||||
results.flatMap((r) => r.frameworks).map((f) => [f.complianceId, f]),
|
||||
);
|
||||
// Watchlist first: a public bucket maps into dozens of frameworks, and
|
||||
// the ones this organization reports on should not be buried.
|
||||
const frameworks = Array.from(byId.values()).sort(
|
||||
(a, b) =>
|
||||
Number(b.inWatchlist) - Number(a.inWatchlist) ||
|
||||
complianceFrameworkLabel(a).localeCompare(
|
||||
complianceFrameworkLabel(b),
|
||||
),
|
||||
);
|
||||
setState({
|
||||
status: "loaded",
|
||||
frameworks,
|
||||
partial: results.some((r) => r.unavailable),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: "unavailable" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [key, attempt]);
|
||||
|
||||
const retry = () => {
|
||||
setState({ status: "loading" });
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
|
||||
const incomplete =
|
||||
state.status === "loaded" &&
|
||||
(state.partial || truncated || checks.size > COMPLIANCE_LOOKUP_LIMIT);
|
||||
|
||||
const open = (framework: FindingComplianceFramework) => {
|
||||
if (!scanId) return;
|
||||
window.open(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: framework.complianceId,
|
||||
framework: framework.framework || framework.name,
|
||||
version: framework.version,
|
||||
scanId,
|
||||
regionFilter: null,
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Compliance affected">
|
||||
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
|
||||
{state.status === "loading" ? (
|
||||
<p role="status" className="text-text-neutral-tertiary text-sm">
|
||||
Loading frameworks…
|
||||
</p>
|
||||
) : state.status === "unavailable" ? (
|
||||
<IssuesLoadError
|
||||
message="Could not load the frameworks for this issue."
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{state.frameworks.length === 0 ? (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
{incomplete
|
||||
? "None of the findings checked map to a framework requirement."
|
||||
: "None of the findings behind this issue map to a framework requirement."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{state.frameworks.map((framework) => (
|
||||
<ComplianceFrameworkChip
|
||||
key={framework.complianceId}
|
||||
framework={framework}
|
||||
isNavigable={Boolean(scanId)}
|
||||
onOpen={open}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{incomplete && (
|
||||
<p className="text-text-neutral-tertiary mt-2 text-xs">
|
||||
Not every finding could be checked, so this list may be
|
||||
incomplete.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { IssueDetail } from "@/components/issues/issue-detail";
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { IssueAttributes } from "@/types/issues";
|
||||
import { ISSUE_FINDINGS_LIMIT, IssueAttributes } from "@/types/issues";
|
||||
|
||||
const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
|
||||
getIssueMock: vi.fn(),
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/actions/issues", () => ({
|
||||
getIssueNotes: async () => [],
|
||||
}));
|
||||
|
||||
vi.mock("@/components/issues/issue-compliance", () => ({
|
||||
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
|
||||
truncated ? <p>Compliance from a capped preview</p> : null,
|
||||
}));
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
@@ -103,6 +107,39 @@ describe("IssueDetail", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells the compliance section when the findings preview was capped", async () => {
|
||||
// Given - one more finding than the preview loads
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: {
|
||||
resource_uids: [],
|
||||
finding_uids: Array.from(
|
||||
{ length: ISSUE_FINDINGS_LIMIT + 1 },
|
||||
(_, i) => `uid-${i}`,
|
||||
),
|
||||
},
|
||||
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
|
||||
},
|
||||
});
|
||||
getIssueFindingsMock.mockResolvedValue([
|
||||
{
|
||||
id: "f1",
|
||||
checkId: "check-a",
|
||||
checkTitle: "Check A",
|
||||
severity: "high",
|
||||
resourceUid: "r1",
|
||||
},
|
||||
]);
|
||||
|
||||
// When
|
||||
render(<IssueDetail id="issue-1" issue={issue} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Compliance from a capped preview"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("claims no outcome for an issue that is not one path", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: [] } },
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getIssue, getIssueFindings } from "@/actions/issues";
|
||||
import { IssueCompliance } from "@/components/issues/issue-compliance";
|
||||
import { IssueFindings } from "@/components/issues/issue-findings";
|
||||
import { IssueGraph } from "@/components/issues/issue-graph";
|
||||
import { IssueTriage } from "@/components/issues/issue-triage";
|
||||
@@ -21,6 +22,7 @@ import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
ATTACKER_START_LABEL,
|
||||
ISSUE_FINDINGS_LIMIT,
|
||||
ISSUE_OUTCOME_LABEL,
|
||||
IssueAttributes,
|
||||
IssueFinding,
|
||||
@@ -64,6 +66,8 @@ type ResourcesState =
|
||||
status: "loaded";
|
||||
resources: string[];
|
||||
findings: IssueFinding[] | undefined;
|
||||
/** `getIssueFindings` capped the preview at ISSUE_FINDINGS_LIMIT. */
|
||||
findingsTruncated: boolean;
|
||||
scanId?: string;
|
||||
providerId?: string;
|
||||
};
|
||||
@@ -107,6 +111,7 @@ export const IssueDetail = ({
|
||||
status: "loaded",
|
||||
resources: attributes.resource_uids,
|
||||
findings,
|
||||
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
|
||||
scanId: detailScanId,
|
||||
providerId: detailProviderId,
|
||||
});
|
||||
@@ -309,6 +314,15 @@ export const IssueDetail = ({
|
||||
total={issue.findings_count}
|
||||
/>
|
||||
)}
|
||||
{state.status === "loaded" &&
|
||||
state.findings &&
|
||||
state.findings.length > 0 && (
|
||||
<IssueCompliance
|
||||
findings={state.findings}
|
||||
scanId={activeScanId}
|
||||
truncated={state.findingsTruncated}
|
||||
/>
|
||||
)}
|
||||
<IssueTriage id={id} status={issue.triage_status} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock("@/actions/issues", () => ({
|
||||
getIssue,
|
||||
getIssueNotes: async () => [],
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-compliance", () => ({
|
||||
IssueCompliance: () => null,
|
||||
}));
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user