mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
feat(ui): triage issues and send them to Jira (#13151)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
1 parent
3bbd551f0f
commit
83152ae1a6
32 files changed
+1244
-188
No files matched your search
@@ -1,20 +1,7 @@
|
||||
import { FindingProps } from "@/types/components";
|
||||
import { IssueFinding } from "@/types/issues";
|
||||
import { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
|
||||
import { ResourceProps } from "@/types/resources";
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
import type { IssueFinding, IssueTriageNote } from "@/types/issues";
|
||||
|
||||
interface IssueFindingResource extends Pick<FindingProps, "id"> {
|
||||
attributes: Pick<
|
||||
FindingProps["attributes"],
|
||||
"check_id" | "check_metadata" | "severity"
|
||||
>;
|
||||
relationships: Pick<FindingProps["relationships"], "resources">;
|
||||
}
|
||||
|
||||
interface IssueFindingsDocument
|
||||
extends JsonApiDocument<IssueFindingResource[]> {
|
||||
included?: JsonApiResource<Pick<ResourceProps["attributes"], "uid">>[];
|
||||
}
|
||||
import type { IssueFindingsDocument, IssueTriageNotesDocument } from "./types";
|
||||
|
||||
export const toIssueFindings = (
|
||||
payload: IssueFindingsDocument | undefined,
|
||||
@@ -33,14 +20,39 @@ export const toIssueFindings = (
|
||||
return [
|
||||
{
|
||||
id: finding.id,
|
||||
uid: finding.attributes.uid,
|
||||
checkId: finding.attributes.check_id,
|
||||
checkTitle:
|
||||
finding.attributes.check_metadata?.checktitle ||
|
||||
finding.attributes.check_id,
|
||||
severity: finding.attributes.severity,
|
||||
// A finding nobody has triaged reads as open, as on the Findings page.
|
||||
triageStatus:
|
||||
finding.attributes.triage_status ?? FINDING_TRIAGE_STATUS.OPEN,
|
||||
resourceUid,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const toIssueTriageNotes = (
|
||||
payload: IssueTriageNotesDocument | undefined,
|
||||
): IssueTriageNote[] | undefined => {
|
||||
if (!Array.isArray(payload?.data)) return undefined;
|
||||
const authors = new Map(
|
||||
(payload.included ?? [])
|
||||
.filter((resource) => resource.type === "users")
|
||||
.map((user) => [user.id, user.attributes.name || user.attributes.email]),
|
||||
);
|
||||
|
||||
return payload.data.map((note) => {
|
||||
const authorId = note.relationships?.created_by?.data?.id;
|
||||
return {
|
||||
id: note.id,
|
||||
body: note.attributes.body,
|
||||
insertedAt: note.attributes.inserted_at,
|
||||
author: authorId ? authors.get(authorId) : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiErrorMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiErrorMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
@@ -14,14 +18,20 @@ vi.mock("@/lib", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import {
|
||||
addIssueNote,
|
||||
getIssue,
|
||||
getIssueFindings,
|
||||
getIssueNotes,
|
||||
getIssues,
|
||||
getIssuesOverview,
|
||||
updateIssueTriage,
|
||||
} from "./issues";
|
||||
|
||||
describe("issues actions", () => {
|
||||
@@ -54,9 +64,12 @@ describe("issues actions", () => {
|
||||
{
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "finding-uid",
|
||||
check_id: "ssh",
|
||||
severity: "critical",
|
||||
check_metadata: { checktitle: "SSH exposed" },
|
||||
// Nobody has triaged it yet.
|
||||
triage_status: null,
|
||||
},
|
||||
relationships: { resources: { data: [{ id: "r1" }, { id: "r2" }] } },
|
||||
},
|
||||
@@ -76,12 +89,15 @@ describe("issues actions", () => {
|
||||
expect(url.searchParams.get("filter[scan]")).toBe("issue-scan");
|
||||
expect(url.searchParams.get("filter[uid__in]")).toBe("finding-uid");
|
||||
expect(url.searchParams.get("include")).toBe("resources");
|
||||
expect(url.searchParams.get("fields[findings]")).toContain("triage_status");
|
||||
expect(findings).toEqual(
|
||||
["instance", "security-group"].map((resourceUid) => ({
|
||||
id: "finding-1",
|
||||
uid: "finding-uid",
|
||||
checkId: "ssh",
|
||||
checkTitle: "SSH exposed",
|
||||
severity: "critical",
|
||||
triageStatus: "open",
|
||||
resourceUid,
|
||||
})),
|
||||
);
|
||||
@@ -118,4 +134,123 @@ describe("issues actions", () => {
|
||||
getIssueFindings("scan-1", ["uid-1"]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("triages through the issue's triage, as findings are triaged", async () => {
|
||||
// Given
|
||||
const response = new Response("{}", { status: 200 });
|
||||
fetchMock.mockResolvedValue(response);
|
||||
handleApiResponseMock.mockResolvedValue({ data: {} });
|
||||
|
||||
// When
|
||||
const result = await updateIssueTriage("issue-1", {
|
||||
status: "risk_accepted",
|
||||
note: "Static site.",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ success: true });
|
||||
// The list shows each issue's triage status.
|
||||
expect(handleApiResponseMock).toHaveBeenCalledWith(response, "/issues");
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://api.example.com/api/v1/issues/issue-1/triage");
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
data: {
|
||||
type: "issue-triages",
|
||||
attributes: { status: "risk_accepted", note: "Static site." },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the API's reason when a triage write is refused", async () => {
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
error: "You do not have permission.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
await expect(addIssueNote("issue-1", "note")).resolves.toEqual({
|
||||
error: "You do not have permission.",
|
||||
});
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
"https://api.example.com/api/v1/issues/issue-1/triage/notes",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the shared, sanitized message when a triage write fails on the server", async () => {
|
||||
// Given: handleApiResponse rejects on a 5xx (see beforeEach)
|
||||
handleApiErrorMock.mockReturnValue({ error: "Something went wrong." });
|
||||
|
||||
// When
|
||||
const result = await addIssueNote("issue-1", "note");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ error: "Something went wrong." });
|
||||
expect(handleApiErrorMock).toHaveBeenCalledWith(new Error("Server error"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
operation: "updating triage",
|
||||
save: () => updateIssueTriage("issue-1", { status: "risk_accepted" }),
|
||||
},
|
||||
{
|
||||
operation: "adding a note",
|
||||
save: () => addIssueNote("issue-1", "Accepted."),
|
||||
},
|
||||
])(
|
||||
"returns a save error when authentication fails while $operation",
|
||||
async ({ save }) => {
|
||||
// Given
|
||||
getAuthHeadersMock.mockRejectedValue(new Error("Session lookup failed"));
|
||||
handleApiErrorMock.mockReturnValue({ error: "Something went wrong." });
|
||||
|
||||
// When / Then
|
||||
await expect(save()).resolves.toEqual({ error: "Something went wrong." });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("names each note's author from the included users", async () => {
|
||||
// Given
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
id: "note-1",
|
||||
attributes: { body: "Accepted.", inserted_at: "2026-10-20" },
|
||||
relationships: { created_by: { data: { type: "users", id: "u1" } } },
|
||||
},
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
id: "note-2",
|
||||
attributes: { body: "Orphan.", inserted_at: "2026-10-19" },
|
||||
relationships: { created_by: { data: null } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "users", id: "u1", attributes: { name: "Ana" } }],
|
||||
});
|
||||
|
||||
// When
|
||||
const notes = await getIssueNotes("issue-1");
|
||||
|
||||
// Then
|
||||
const url = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(url.pathname).toBe("/api/v1/issues/issue-1/triage/notes");
|
||||
expect(url.searchParams.get("include")).toBe("created_by");
|
||||
expect(url.searchParams.get("page[size]")).toBe("100");
|
||||
expect(notes).toEqual([
|
||||
{
|
||||
id: "note-1",
|
||||
body: "Accepted.",
|
||||
insertedAt: "2026-10-20",
|
||||
author: "Ana",
|
||||
},
|
||||
{
|
||||
id: "note-2",
|
||||
body: "Orphan.",
|
||||
insertedAt: "2026-10-19",
|
||||
author: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
ISSUE_FINDINGS_LIMIT,
|
||||
IssueDetailResponse,
|
||||
IssueFinding,
|
||||
IssueResponse,
|
||||
IssuesOverviewResponse,
|
||||
IssueTriageNote,
|
||||
} from "@/types/issues";
|
||||
|
||||
import { toIssueFindings } from "./issues.adapter";
|
||||
import { toIssueFindings, toIssueTriageNotes } from "./issues.adapter";
|
||||
import {
|
||||
ISSUE_TRIAGE_WRITE_METHOD,
|
||||
type IssueTriageWriteMethod,
|
||||
type IssueWriteResult,
|
||||
type UpdateIssueTriageInput,
|
||||
} from "./types";
|
||||
|
||||
const withFilters = (
|
||||
url: URL,
|
||||
@@ -107,7 +114,7 @@ export const getIssueFindings = async (
|
||||
url.searchParams.set("include", "resources");
|
||||
url.searchParams.set(
|
||||
"fields[findings]",
|
||||
"check_id,severity,check_metadata,resources",
|
||||
"uid,check_id,severity,check_metadata,triage_status,resources",
|
||||
);
|
||||
url.searchParams.set("fields[resources]", "uid");
|
||||
url.searchParams.set("page[size]", String(uids.length));
|
||||
@@ -120,3 +127,72 @@ export const getIssueFindings = async (
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Write one issue triage request; any non-2xx comes back as an error to show. */
|
||||
const writeIssueTriage = async (
|
||||
path: string,
|
||||
method: IssueTriageWriteMethod,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<IssueWriteResult> => {
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
// The list shows each issue's triage status.
|
||||
const result = await handleApiResponse(response, "/issues");
|
||||
return result?.error ? { error: result.error } : { success: true };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/** Set the status of the path an issue describes, with an optional note. */
|
||||
export const updateIssueTriage = async (
|
||||
id: string,
|
||||
{ status, note }: UpdateIssueTriageInput,
|
||||
): Promise<IssueWriteResult> =>
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
|
||||
{
|
||||
type: "issue-triages",
|
||||
attributes: { status, ...(note ? { note } : {}) },
|
||||
},
|
||||
);
|
||||
|
||||
/** Add a note to an issue's triage without changing its status. */
|
||||
export const addIssueNote = async (
|
||||
id: string,
|
||||
body: string,
|
||||
): Promise<IssueWriteResult> =>
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
attributes: { body },
|
||||
},
|
||||
);
|
||||
|
||||
export const getIssueNotes = async (
|
||||
id: string,
|
||||
): Promise<IssueTriageNote[] | undefined> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
);
|
||||
url.searchParams.set("include", "created_by");
|
||||
// shortcut: newest 100 notes only (the API's max page size; its default is 10), page through links.next once an issue's history outgrows it
|
||||
url.searchParams.set("page[size]", "100");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
return toIssueTriageNotes(await handleApiResponse(response));
|
||||
} catch (error) {
|
||||
console.error("Error fetching issue notes:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FindingProps } from "@/types/components";
|
||||
import type {
|
||||
FindingTriageManualStatus,
|
||||
FindingTriageStatus,
|
||||
} from "@/types/findings-triage";
|
||||
import type { IssueTriageNote } from "@/types/issues";
|
||||
import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
|
||||
import type { ResourceProps } from "@/types/resources";
|
||||
import type { UserAttributes, UserData } from "@/types/users";
|
||||
|
||||
export interface IssueFindingAttributes
|
||||
extends Pick<
|
||||
FindingProps["attributes"],
|
||||
"uid" | "check_id" | "check_metadata" | "severity"
|
||||
> {
|
||||
triage_status?: FindingTriageStatus | null;
|
||||
}
|
||||
|
||||
export interface IssueFindingResource extends Pick<FindingProps, "id"> {
|
||||
attributes: IssueFindingAttributes;
|
||||
relationships: Pick<FindingProps["relationships"], "resources">;
|
||||
}
|
||||
|
||||
export interface IssueFindingsDocument
|
||||
extends JsonApiDocument<IssueFindingResource[]> {
|
||||
included?: JsonApiResource<Pick<ResourceProps["attributes"], "uid">>[];
|
||||
}
|
||||
|
||||
export interface IssueTriageNoteAttributes
|
||||
extends Pick<IssueTriageNote, "body"> {
|
||||
inserted_at: IssueTriageNote["insertedAt"];
|
||||
}
|
||||
|
||||
export interface IssueTriageNoteAuthorRelationship {
|
||||
data: Pick<UserData, "id"> | null;
|
||||
}
|
||||
|
||||
export interface IssueTriageNoteRelationships {
|
||||
created_by?: IssueTriageNoteAuthorRelationship;
|
||||
}
|
||||
|
||||
export interface IssueTriageNoteResource
|
||||
extends JsonApiResource<IssueTriageNoteAttributes> {
|
||||
relationships?: IssueTriageNoteRelationships;
|
||||
}
|
||||
|
||||
export interface IssueTriageNotesDocument
|
||||
extends JsonApiDocument<IssueTriageNoteResource[]> {
|
||||
included?: JsonApiResource<Partial<Pick<UserAttributes, "name" | "email">>>[];
|
||||
}
|
||||
|
||||
export type IssueWriteResult = { success: true } | { error: string };
|
||||
|
||||
export interface UpdateIssueTriageInput {
|
||||
status: FindingTriageManualStatus;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const ISSUE_TRIAGE_WRITE_METHOD = {
|
||||
UPDATE: "PATCH",
|
||||
ADD_NOTE: "POST",
|
||||
} as const;
|
||||
|
||||
export type IssueTriageWriteMethod =
|
||||
(typeof ISSUE_TRIAGE_WRITE_METHOD)[keyof typeof ISSUE_TRIAGE_WRITE_METHOD];
|
||||
+10
-2
@@ -588,7 +588,8 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
|
||||
/**
|
||||
* Click every resource that the fixture's relationships connect to a finding.
|
||||
* Findings are hidden by default in the full graph view (Tier 1) — clicking
|
||||
* their adjacent resources reveals them.
|
||||
* their adjacent resources reveals them. Restore the full view after each
|
||||
* expansion because its contextual fit can move the next resource off-screen.
|
||||
*/
|
||||
async expandAllFindings(): Promise<void> {
|
||||
const findingIds = new Set(
|
||||
@@ -604,8 +605,15 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
|
||||
for (const id of Array.from(resourceWithFindingIds)) {
|
||||
const el = this.getNodeById(id);
|
||||
if (el) {
|
||||
const beforeExpansion = await this.waitForViewportSettled();
|
||||
await this.user.click(el);
|
||||
await this.waitForTransition(50);
|
||||
// Measuring new findings can delay the automatic fit. A quiet viewport
|
||||
// alone does not prove that this fit has started or finished.
|
||||
await this.waitForViewportChange(beforeExpansion);
|
||||
await this.waitForViewportSettled();
|
||||
await this.fit();
|
||||
await this.waitForNodesInViewport(this.renderedNodeIds);
|
||||
await this.waitForViewportSettled();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -616,11 +616,15 @@ describe("auto-fitting the viewport", () => {
|
||||
// their positions can sit outside the framed viewport. Expanding the
|
||||
// resources should re-fit so the user does not have to hunt for the
|
||||
// newly visible findings off-screen.
|
||||
const before = graph.viewportTransform;
|
||||
const before = await graph.waitForViewportSettled();
|
||||
expect(before).toBeTruthy();
|
||||
|
||||
await graph.expandAllFindings();
|
||||
// Use a single resource click so only the automatic fit can recover the
|
||||
// findings; expandAllFindings also uses the toolbar to frame the full graph.
|
||||
await graph.clickNode("s3-1");
|
||||
await graph.waitForViewportChange(before);
|
||||
await graph.waitForViewportSettled();
|
||||
await graph.waitForNodesInViewport(["s3-1", "f-1"]);
|
||||
|
||||
expect(graph.viewportTransform).not.toBe(before);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ const ISSUE_FILTER_KEYS = [
|
||||
"filter[severity]",
|
||||
"filter[provider_type]",
|
||||
"filter[provider_alias]",
|
||||
"filter[triage_status]",
|
||||
"filter[triage_status__in]",
|
||||
...ISSUE_SCOPE_KEYS,
|
||||
];
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
MANUAL_PASS_PROVENANCE,
|
||||
MANUAL_PASS_NOTE_REQUIRED_COPY,
|
||||
type FindingTriageContext,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageModalStatus,
|
||||
@@ -41,10 +42,7 @@ import {
|
||||
isTriageStatusLocked,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
MANUAL_PASS_NOTE_REQUIRED_COPY,
|
||||
} from "./finding-triage-status-control";
|
||||
import { FindingTriageStatusControl } from "./finding-triage-status-control";
|
||||
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
|
||||
|
||||
interface FindingNoteModalProps {
|
||||
|
||||
@@ -92,7 +92,6 @@ import {
|
||||
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusBadge,
|
||||
FindingTriageStatusCell,
|
||||
} from "./finding-triage-cells";
|
||||
|
||||
@@ -803,22 +802,6 @@ describe("finding triage cells", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a read-only triage status badge with the status color", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<FindingTriageStatusBadge
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Triage:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info");
|
||||
});
|
||||
|
||||
it("should disable table status mutation when no update handler is wired", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { applyOptimisticTriageSummaryUpdate } from "@/lib/finding-triage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
@@ -39,10 +38,7 @@ import {
|
||||
FindingNoteModal,
|
||||
type FindingNoteModalMode,
|
||||
} from "./finding-note-modal";
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
TRIAGE_STATUS_TEXT_CLASS,
|
||||
} from "./finding-triage-status-control";
|
||||
import { FindingTriageStatusControl } from "./finding-triage-status-control";
|
||||
|
||||
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
|
||||
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
|
||||
@@ -340,28 +336,6 @@ export function FindingTriageStatusCell({
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only triage status indicator, e.g. for the side drawer header where the
|
||||
// editable picker would be out of place among the status/severity badges.
|
||||
export function FindingTriageStatusBadge({
|
||||
triage,
|
||||
}: {
|
||||
triage: FindingTriageSummary;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-text-neutral-tertiary text-xs">Triage:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
TRIAGE_STATUS_TEXT_CLASS[triage.status],
|
||||
)}
|
||||
>
|
||||
{triage.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingNoteActionItem({
|
||||
triage,
|
||||
findingContext = { title: "Finding" },
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
|
||||
FINDING_TRIAGE_MODAL_STATUS_VALUES,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
type FindingTriageManualStatus,
|
||||
type FindingTriageModalStatus,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageSummary,
|
||||
type FindingTriageUpdateHandler,
|
||||
getFindingTriageMuteInfoCopy,
|
||||
@@ -29,89 +21,7 @@ import {
|
||||
isTriageStatusLocked,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
type TriageStatusPickerSize = NonNullable<
|
||||
ComponentProps<typeof SelectTrigger>["size"]
|
||||
>;
|
||||
|
||||
export const TRIAGE_STATUS_TEXT_CLASS = {
|
||||
open: "text-text-error-primary",
|
||||
under_review: "text-text-warning-primary",
|
||||
remediating: "text-bg-data-info",
|
||||
resolved: "text-bg-pass",
|
||||
risk_accepted: "text-bg-pass",
|
||||
false_positive: "text-text-neutral-secondary",
|
||||
reopened: "text-text-error-primary",
|
||||
} as const satisfies Record<FindingTriageStatus, string>;
|
||||
|
||||
const MUTELIST_CONFIRMATION_TITLE = "Mute finding?";
|
||||
export const MANUAL_PASS_NOTE_REQUIRED_COPY =
|
||||
"Add a Triage Note explaining why this finding passes.";
|
||||
|
||||
function TriageStatusPicker({
|
||||
disabled,
|
||||
size = "sm",
|
||||
value,
|
||||
statusValues,
|
||||
showManualPassTooltip = false,
|
||||
onValueChange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
size?: TriageStatusPickerSize;
|
||||
value: FindingTriageStatus;
|
||||
statusValues: readonly FindingTriageModalStatus[];
|
||||
showManualPassTooltip?: boolean;
|
||||
onValueChange: (status: FindingTriageModalStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextStatus) => {
|
||||
if (statusValues.includes(nextStatus as FindingTriageModalStatus)) {
|
||||
onValueChange(nextStatus as FindingTriageModalStatus);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Triage status"
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
iconSize="sm"
|
||||
>
|
||||
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[value])}>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[value]}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusValues.map((status) => {
|
||||
const shouldExplainManualPass =
|
||||
showManualPassTooltip && status === FINDING_TRIAGE_STATUS.RESOLVED;
|
||||
return (
|
||||
<SelectItem
|
||||
key={status}
|
||||
value={status}
|
||||
aria-label={FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
>
|
||||
<span
|
||||
className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[status])}
|
||||
>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
</span>
|
||||
{shouldExplainManualPass && (
|
||||
<span
|
||||
className="text-text-neutral-secondary text-xs font-normal normal-case"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{MANUAL_PASS_NOTE_REQUIRED_COPY}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type TableStatusControlProps = {
|
||||
origin: typeof FINDING_TRIAGE_ORIGIN.TABLE;
|
||||
|
||||
-6
@@ -524,12 +524,6 @@ vi.mock("../finding-triage-cells", () => ({
|
||||
) : (
|
||||
<span>-</span>
|
||||
),
|
||||
FindingTriageStatusBadge: ({ triage }: { triage: { label: string } }) => (
|
||||
<div>
|
||||
<span>Triage:</span>
|
||||
<span>{triage.label}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./resource-detail-skeleton", () => ({
|
||||
|
||||
+2
-2
@@ -75,6 +75,7 @@ import {
|
||||
type QueryEditorLanguage,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
|
||||
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
|
||||
import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context";
|
||||
import { getFailingForLabel, formatDuration } from "@/lib/date-utils";
|
||||
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
@@ -97,7 +98,6 @@ import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusBadge,
|
||||
FindingTriageStatusCell,
|
||||
} from "../finding-triage-cells";
|
||||
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
|
||||
@@ -534,7 +534,7 @@ export function ResourceDetailDrawerContent({
|
||||
{findingIsMuted !== undefined && (
|
||||
<Muted isMuted={findingIsMuted} mutedReason={findingMutedReason} />
|
||||
)}
|
||||
{findingTriage && <FindingTriageStatusBadge triage={findingTriage} />}
|
||||
{findingTriage && <TriageStatusBadge status={findingTriage.status} />}
|
||||
</div>
|
||||
|
||||
{showCheckMetaContent ? (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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";
|
||||
|
||||
const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
|
||||
@@ -15,8 +16,12 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssue: getIssueMock,
|
||||
getIssueFindings: getIssueFindingsMock,
|
||||
getIssueNotes: async () => [],
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-graph", () => ({
|
||||
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
|
||||
<div data-testid="issue-graph">{resourceUids.length} resources</div>
|
||||
@@ -35,6 +40,7 @@ const issue = {
|
||||
legs: {},
|
||||
fix: "Review these findings individually.",
|
||||
findings_count: 300,
|
||||
triage_status: "open",
|
||||
inserted_at: "",
|
||||
updated_at: "",
|
||||
} as unknown as IssueAttributes;
|
||||
@@ -43,6 +49,7 @@ describe("IssueDetail", () => {
|
||||
beforeEach(() => {
|
||||
getIssueMock.mockReset();
|
||||
getIssueFindingsMock.mockResolvedValue([]);
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("links to the issue's scan findings and reveals every folded resource", async () => {
|
||||
@@ -108,4 +115,93 @@ describe("IssueDetail", () => {
|
||||
"finding-uid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists each folded finding once, with its own triage and a link to it", async () => {
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1", "f-2"] },
|
||||
},
|
||||
});
|
||||
const finding = {
|
||||
id: "finding-1",
|
||||
uid: "f-1",
|
||||
checkId: "s3_bucket_public_access",
|
||||
checkTitle: "Bucket is public",
|
||||
severity: "critical",
|
||||
triageStatus: "risk_accepted",
|
||||
resourceUid: "bucket",
|
||||
};
|
||||
getIssueFindingsMock.mockResolvedValue([
|
||||
finding,
|
||||
// The same finding on a second resource is still one finding.
|
||||
{ ...finding, resourceUid: "other-bucket" },
|
||||
{
|
||||
...finding,
|
||||
id: "finding-2",
|
||||
uid: "f-2",
|
||||
checkTitle: "Bucket has no logging",
|
||||
triageStatus: "open",
|
||||
},
|
||||
]);
|
||||
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
|
||||
|
||||
const section = await screen.findByRole("region", { name: "Findings" });
|
||||
const link = within(section).getByRole("link", {
|
||||
name: "Bucket is public",
|
||||
});
|
||||
const destination = new URL(link.getAttribute("href")!, "https://app.test");
|
||||
expect(Object.fromEntries(destination.searchParams)).toEqual({
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[check_id__in]": "s3_bucket_public_access",
|
||||
"filter[resource_uid]": "other-bucket",
|
||||
"filter[muted]": "include",
|
||||
expandedCheckId: "s3_bucket_public_access",
|
||||
});
|
||||
expect(within(section).getAllByRole("listitem")).toHaveLength(2);
|
||||
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
|
||||
expect(within(section).getByText("Showing 2 of 300.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the global Jira flow with unique finding IDs", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
getIssueMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1"] },
|
||||
},
|
||||
});
|
||||
const finding = {
|
||||
id: "finding-1",
|
||||
uid: "f-1",
|
||||
checkId: "s3_public",
|
||||
checkTitle: "Bucket is public",
|
||||
severity: "critical",
|
||||
triageStatus: "open",
|
||||
resourceUid: "bucket",
|
||||
};
|
||||
getIssueFindingsMock.mockResolvedValue([
|
||||
finding,
|
||||
{ ...finding, resourceUid: "other-bucket" },
|
||||
{ ...finding, id: "finding-2", uid: "f-2" },
|
||||
]);
|
||||
const view = render(
|
||||
<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Send to Jira" }),
|
||||
);
|
||||
view.unmount();
|
||||
|
||||
// Then: the globally hosted modal survives the detail closing.
|
||||
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
|
||||
findingTitle: issue.title,
|
||||
selection: {
|
||||
kind: "target-list",
|
||||
targetType: "finding_id",
|
||||
targetIds: ["finding-1", "finding-2"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Send } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getIssue, getIssueFindings } from "@/actions/issues";
|
||||
import { IssueFindings } from "@/components/issues/issue-findings";
|
||||
import { IssueGraph } from "@/components/issues/issue-graph";
|
||||
import { IssueTriage } from "@/components/issues/issue-triage";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { CodeSnippet } from "@/components/shadcn/code-snippet";
|
||||
import { legBadgeVariant } from "@/lib/issues/verdicts";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
IssueAttributes,
|
||||
IssueFinding,
|
||||
@@ -64,6 +70,9 @@ export const IssueDetail = ({
|
||||
scanId,
|
||||
providerId,
|
||||
}: IssueDetailProps) => {
|
||||
const openJiraDispatch = useJiraDispatchStore(
|
||||
(state) => state.openJiraDispatch,
|
||||
);
|
||||
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
// Resource UIDs are only included by the detail endpoint, fetched on expansion.
|
||||
@@ -111,6 +120,20 @@ export const IssueDetail = ({
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
|
||||
const uniqueFindings =
|
||||
state.status === "loaded"
|
||||
? Array.from(
|
||||
new Map(
|
||||
state.findings?.map((finding) => [finding.id, finding]),
|
||||
).values(),
|
||||
)
|
||||
: [];
|
||||
const jiraPayload = createJiraDispatchPayload({
|
||||
findingTitle: issue.title,
|
||||
targetIds: uniqueFindings.map((finding) => finding.id),
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
description: `Send ${uniqueFindings.length} of ${issue.findings_count} findings on this attack path.`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 py-4">
|
||||
@@ -219,16 +242,41 @@ export const IssueDetail = ({
|
||||
<section aria-label="Remediation">
|
||||
<h4 className="mb-3 text-sm font-semibold">Remediation</h4>
|
||||
<p className="text-text-neutral-secondary text-sm">{issue.fix}</p>
|
||||
{activeScanId && (
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{activeScanId && (
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
|
||||
>
|
||||
Open this scan's failed findings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{jiraPayload && (
|
||||
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
|
||||
// findings), not every finding of a larger issue; page through
|
||||
// finding_uids if tickets for big issues need all of them.
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openJiraDispatch(jiraPayload)}
|
||||
>
|
||||
Open this scan's failed findings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Send aria-hidden="true" />
|
||||
Send to Jira
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{state.status === "loaded" &&
|
||||
state.findings &&
|
||||
state.findings.length > 0 && (
|
||||
<IssueFindings
|
||||
findings={uniqueFindings}
|
||||
scanId={activeScanId}
|
||||
total={issue.findings_count}
|
||||
/>
|
||||
)}
|
||||
<IssueTriage id={id} status={issue.triage_status} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
|
||||
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
|
||||
import { MUTED_FILTER } from "@/lib/findings-filters";
|
||||
|
||||
import type { IssueFindingsProps } from "./types";
|
||||
|
||||
/**
|
||||
* The findings the issue folds, each with its own triage. That triage stays on
|
||||
* the finding: the issue's status answers for the combination, the finding's
|
||||
* for whether that one check is right.
|
||||
*/
|
||||
export const IssueFindings = ({
|
||||
findings,
|
||||
scanId,
|
||||
total,
|
||||
}: IssueFindingsProps) => {
|
||||
return (
|
||||
<section aria-label="Findings">
|
||||
<h4 className="mb-3 text-sm font-semibold">
|
||||
Findings ({total.toLocaleString()})
|
||||
</h4>
|
||||
<ul className="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
{findings.map((finding) => (
|
||||
<li key={finding.id} className="min-w-0">
|
||||
<Card variant="inner" padding="md" className="h-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{scanId ? (
|
||||
<div className="min-w-0">
|
||||
<Button
|
||||
asChild
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
className="max-w-full"
|
||||
>
|
||||
<Link
|
||||
href={`/findings?${new URLSearchParams({
|
||||
"filter[scan__in]": scanId,
|
||||
"filter[check_id__in]": finding.checkId,
|
||||
"filter[resource_uid]": finding.resourceUid,
|
||||
"filter[muted]": MUTED_FILTER.INCLUDE,
|
||||
expandedCheckId: finding.checkId,
|
||||
})}`}
|
||||
title={finding.checkTitle}
|
||||
>
|
||||
<span className="truncate">{finding.checkTitle}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-neutral-primary min-w-0 text-sm break-words">
|
||||
{finding.checkTitle}
|
||||
</span>
|
||||
)}
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-text-neutral-tertiary min-w-0 truncate font-mono text-xs">
|
||||
{finding.checkId}
|
||||
</span>
|
||||
<TriageStatusBadge
|
||||
status={finding.triageStatus}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{findings.length < total && (
|
||||
<p className="text-text-neutral-tertiary mt-2 text-xs">
|
||||
Showing {findings.length.toLocaleString()} of {total.toLocaleString()}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -38,9 +38,11 @@ describe("buildIssueGraph", () => {
|
||||
assessedIssue.legs.reachability.evidence = ["ssh · instance"];
|
||||
const findings = resources.map((resourceUid, index) => ({
|
||||
id: `finding-${index}`,
|
||||
uid: `finding-uid-${index}`,
|
||||
checkId: index === 2 ? "rdp" : "ssh",
|
||||
checkTitle: index === 2 ? "RDP exposed" : "SSH exposed",
|
||||
severity: index === 0 ? ("critical" as const) : ("high" as const),
|
||||
triageStatus: "open" as const,
|
||||
resourceUid,
|
||||
}));
|
||||
|
||||
@@ -78,9 +80,11 @@ describe("buildIssueGraph", () => {
|
||||
const view = buildIssueGraph(assessedIssue, resources, [
|
||||
{
|
||||
id: "folded",
|
||||
uid: "folded-uid",
|
||||
checkId: "ssh",
|
||||
checkTitle: "SSH exposed",
|
||||
severity: "critical",
|
||||
triageStatus: "open",
|
||||
resourceUid,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -76,9 +76,11 @@ describe("IssueGraph finding lookup", () => {
|
||||
findings={[
|
||||
{
|
||||
id: "folded-finding",
|
||||
uid: "folded-finding-uid",
|
||||
checkId: "ssh",
|
||||
checkTitle: "SSH exposed",
|
||||
severity: "critical",
|
||||
triageStatus: "open",
|
||||
resourceUid: "bucket",
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { IssueTriage } from "./issue-triage";
|
||||
|
||||
const {
|
||||
addIssueNoteMock,
|
||||
getIssueNotesMock,
|
||||
refreshMock,
|
||||
toastMock,
|
||||
updateIssueTriageMock,
|
||||
} = vi.hoisted(() => ({
|
||||
addIssueNoteMock: vi.fn(),
|
||||
getIssueNotesMock: vi.fn(),
|
||||
refreshMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
updateIssueTriageMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: refreshMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
addIssueNote: addIssueNoteMock,
|
||||
getIssueNotes: getIssueNotesMock,
|
||||
updateIssueTriage: updateIssueTriageMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
describe("IssueTriage", () => {
|
||||
beforeAll(() => {
|
||||
// Radix Select needs pointer capture and scrollIntoView, which jsdom lacks.
|
||||
for (const name of [
|
||||
"hasPointerCapture",
|
||||
"setPointerCapture",
|
||||
"releasePointerCapture",
|
||||
"scrollIntoView",
|
||||
]) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getIssueNotesMock.mockResolvedValue([
|
||||
{
|
||||
id: "note-1",
|
||||
body: "Public by design.",
|
||||
insertedAt: "2026-10-20T10:00:00Z",
|
||||
author: "Ana",
|
||||
},
|
||||
]);
|
||||
updateIssueTriageMock.mockResolvedValue({ success: true });
|
||||
addIssueNoteMock.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it("lists the notes already on the issue, with their author", async () => {
|
||||
render(<IssueTriage id="issue-1" status="open" />);
|
||||
|
||||
expect(await screen.findByText("Public by design.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ana")).toBeInTheDocument();
|
||||
expect(screen.getByText("Oct 20, 2026")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a new status together with its note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<IssueTriage id="issue-1" status="open" />);
|
||||
expect(screen.getByRole("button", { name: "Save triage" })).toBeDisabled();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Triage note" }),
|
||||
" Static site. ",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save triage" }));
|
||||
|
||||
// Then
|
||||
expect(updateIssueTriageMock).toHaveBeenCalledWith("issue-1", {
|
||||
status: "risk_accepted",
|
||||
note: "Static site.",
|
||||
});
|
||||
expect(addIssueNoteMock).not.toHaveBeenCalled();
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
expect(screen.getByRole("textbox", { name: "Triage note" })).toHaveValue(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("records a note alone without touching the status", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<IssueTriage id="issue-1" status="remediating" />);
|
||||
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Triage note" }),
|
||||
"Asked the owner.",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save triage" }));
|
||||
|
||||
expect(addIssueNoteMock).toHaveBeenCalledWith(
|
||||
"issue-1",
|
||||
"Asked the owner.",
|
||||
);
|
||||
expect(updateIssueTriageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows a status someone else set, so a note alone does not undo it", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<IssueTriage id="issue-1" status="open" />);
|
||||
|
||||
// When - a refresh brings another user's decision
|
||||
rerender(<IssueTriage id="issue-1" status="remediating" />);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Triage note" }),
|
||||
"Asked the owner.",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save triage" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
expect(addIssueNoteMock).toHaveBeenCalledWith(
|
||||
"issue-1",
|
||||
"Asked the owner.",
|
||||
);
|
||||
expect(updateIssueTriageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks the note while it is being saved", async () => {
|
||||
// Given
|
||||
let finish: (value: { success: true }) => void = () => {};
|
||||
addIssueNoteMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<IssueTriage id="issue-1" status="open" />);
|
||||
const textbox = screen.getByRole("textbox", { name: "Triage note" });
|
||||
|
||||
// When
|
||||
await user.type(textbox, "Asked the owner.");
|
||||
await user.click(screen.getByRole("button", { name: "Save triage" }));
|
||||
|
||||
// Then
|
||||
expect(textbox).toBeDisabled();
|
||||
finish({ success: true });
|
||||
await waitFor(() => expect(textbox).toBeEnabled());
|
||||
});
|
||||
|
||||
it("keeps the note and says why when the save fails", async () => {
|
||||
// Given
|
||||
updateIssueTriageMock.mockResolvedValue({
|
||||
error: "You do not have permission to perform this action.",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<IssueTriage id="issue-1" status="open" />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "False Positive" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Triage note" }), "x");
|
||||
await user.click(screen.getByRole("button", { name: "Save triage" }));
|
||||
|
||||
// Then
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
description: "You do not have permission to perform this action.",
|
||||
}),
|
||||
);
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("textbox", { name: "Triage note" })).toHaveValue(
|
||||
"x",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the notes cannot be loaded, and loads them on retry", async () => {
|
||||
const user = userEvent.setup();
|
||||
getIssueNotesMock.mockResolvedValueOnce(undefined);
|
||||
render(<IssueTriage id="issue-1" status="open" />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load the notes for this issue.",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(await screen.findByText("Public by design.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
import {
|
||||
addIssueNote,
|
||||
getIssueNotes,
|
||||
updateIssueTriage,
|
||||
} from "@/actions/issues";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { DateWithTime } from "@/components/shadcn/entities";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
|
||||
import {
|
||||
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
isManualStatus,
|
||||
} from "@/types/findings-triage";
|
||||
import { IssueTriageNote } from "@/types/issues";
|
||||
|
||||
import type { IssueTriageProps } from "./types";
|
||||
|
||||
/**
|
||||
* The decision on the path, and why.
|
||||
*
|
||||
* It belongs to the account and the pattern, not to this scan's row, so it
|
||||
* still applies after the next scan. The verdict beside it is the engine's:
|
||||
* accepting a risk does not make a path unexploitable.
|
||||
*/
|
||||
export const IssueTriage = ({ id, status }: IssueTriageProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [nextStatus, setNextStatus] = useState(status);
|
||||
const [shownStatus, setShownStatus] = useState(status);
|
||||
const [note, setNote] = useState("");
|
||||
const [notes, setNotes] = useState<IssueTriageNote[] | undefined>([]);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
// Follow the status the server now reports (a refresh after a save, or
|
||||
// someone else's decision), so a note-only save never writes back a stale one.
|
||||
if (status !== shownStatus) {
|
||||
setShownStatus(status);
|
||||
setNextStatus(status);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getIssueNotes(id).then((loaded) => {
|
||||
if (!cancelled) setNotes(loaded);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, reload]);
|
||||
|
||||
const statusChanged = nextStatus !== status;
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
const save = () =>
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
statusChanged && isManualStatus(nextStatus)
|
||||
? await updateIssueTriage(id, {
|
||||
status: nextStatus,
|
||||
note: trimmedNote || undefined,
|
||||
})
|
||||
: await addIssueNote(id, trimmedNote);
|
||||
|
||||
if ("error" in result) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Triage not saved",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setNote("");
|
||||
setReload((value) => value + 1);
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<section aria-label="Triage" className="flex flex-col gap-3">
|
||||
<h4 className="text-sm font-semibold">Triage</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="w-40">
|
||||
<TriageStatusPicker
|
||||
disabled={pending}
|
||||
value={nextStatus}
|
||||
statusValues={FINDING_TRIAGE_MANUAL_STATUS_VALUES}
|
||||
onValueChange={setNextStatus}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={pending || (!statusChanged && !trimmedNote)}
|
||||
>
|
||||
{pending ? "Saving…" : "Save triage"}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label="Triage note"
|
||||
placeholder="Why this decision? The next person to read this issue only has what you write here."
|
||||
value={note}
|
||||
maxLength={FINDING_TRIAGE_NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
// A successful save clears the note: text typed meanwhile would be lost.
|
||||
disabled={pending}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
{notes === undefined ? (
|
||||
<IssuesLoadError
|
||||
message="Could not load the notes for this issue."
|
||||
onRetry={() => setReload((value) => value + 1)}
|
||||
/>
|
||||
) : notes.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{notes.map((item) => (
|
||||
<li key={item.id} className="flex flex-col gap-1">
|
||||
<p className="text-text-neutral-secondary text-sm whitespace-pre-wrap">
|
||||
{item.body}
|
||||
</p>
|
||||
<div className="text-text-neutral-tertiary flex items-center gap-2 text-xs">
|
||||
<span>{item.author ?? "Unknown"}</span>
|
||||
<DateWithTime inline dateTime={item.insertedAt} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,13 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
|
||||
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
|
||||
vi.mock("@/actions/issues", () => ({ getIssue }));
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssue,
|
||||
getIssueNotes: async () => [],
|
||||
}));
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
vi.mock("@/components/issues/issue-graph", () => ({ IssueGraph: () => null }));
|
||||
|
||||
const issue: IssueResponse["data"][number] = {
|
||||
@@ -31,6 +37,8 @@ const issue: IssueResponse["data"][number] = {
|
||||
verdict: "likely",
|
||||
severity: "high",
|
||||
findings_count: 3,
|
||||
triage_status: "open",
|
||||
notes_count: 0,
|
||||
fix: "Restrict access",
|
||||
inserted_at: "",
|
||||
updated_at: "",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/shadcn/button/button";
|
||||
import { EntityInfo } from "@/components/shadcn/entities";
|
||||
import { DataTable, DataTableColumnHeader } from "@/components/shadcn/table";
|
||||
import { TableCell, TableRow } from "@/components/shadcn/table/table";
|
||||
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
|
||||
import { VERDICTS } from "@/lib/issues/verdicts";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import { IssueResponse } from "@/types/issues";
|
||||
@@ -107,6 +108,14 @@ const columns: ColumnDef<IssueRow>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "triage",
|
||||
header: "Triage",
|
||||
cell: ({ row }) => {
|
||||
const triage = row.original.attributes.triage_status;
|
||||
return <TriageStatusBadge status={triage} showLabel={false} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const IssuesTable = ({
|
||||
|
||||
@@ -4,9 +4,16 @@ import { page, userEvent } from "vitest/browser";
|
||||
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import {
|
||||
getFindingGroups,
|
||||
getFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import { getScan } from "@/actions/scans";
|
||||
import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart";
|
||||
import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues";
|
||||
import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list";
|
||||
import { applyDefaultMutedFilter, extractFiltersAndQuery } from "@/lib";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
|
||||
|
||||
const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`;
|
||||
@@ -21,6 +28,8 @@ const issue: IssueResource = {
|
||||
verdict: "likely",
|
||||
severity: "high",
|
||||
findings_count: 3,
|
||||
triage_status: "open",
|
||||
notes_count: 0,
|
||||
fix: "Enable S3 Block Public Access and review the bucket policy.",
|
||||
inserted_at: "",
|
||||
updated_at: "",
|
||||
@@ -82,6 +91,20 @@ const setup = () => {
|
||||
}),
|
||||
),
|
||||
http.get("*/issues", () => HttpResponse.json({ data: [issue] })),
|
||||
http.get("*/issues/issue-1/triage/notes", () =>
|
||||
HttpResponse.json({
|
||||
data: [
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
id: "note-1",
|
||||
attributes: {
|
||||
body: "Public by design.",
|
||||
inserted_at: "2026-10-20T10:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
http.get("*/issues/issue-1", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
@@ -101,8 +124,10 @@ const setup = () => {
|
||||
type: "findings",
|
||||
id: "f1",
|
||||
attributes: {
|
||||
uid: "finding-uid-1",
|
||||
check_id: "s3_public",
|
||||
severity: "critical",
|
||||
triage_status: "risk_accepted",
|
||||
check_metadata: { checktitle: "S3 bucket is public" },
|
||||
},
|
||||
relationships: {
|
||||
@@ -124,6 +149,82 @@ describe("Issues responsive layout", () => {
|
||||
await page.viewport(1280, 800);
|
||||
});
|
||||
|
||||
it("opens the selected finding's group and muted resource through the destination actions", async () => {
|
||||
// Given: the destination returns the finding only for its supported filters.
|
||||
setup();
|
||||
worker.use(
|
||||
http.get("*/scans/scan-1", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
id: "scan-1",
|
||||
type: "scans",
|
||||
attributes: {
|
||||
inserted_at: "2026-10-20T10:00:00Z",
|
||||
completed_at: "2026-10-20T11:00:00Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
http.get("*/finding-groups", ({ request }) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const matches =
|
||||
params.get("filter[check_id__in]") === "s3_public" &&
|
||||
params.get("filter[resource_uid]") === resource &&
|
||||
params.get("filter[scan__in]") === "scan-1" &&
|
||||
params.get("filter[inserted_at]") === "2026-10-20" &&
|
||||
params.get("filter[muted]") === "include";
|
||||
return HttpResponse.json({
|
||||
data: matches ? [{ id: "s3_public", type: "finding-groups" }] : [],
|
||||
});
|
||||
}),
|
||||
http.get("*/finding-groups/s3_public/resources", ({ request }) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const matches =
|
||||
params.get("filter[resource_uid]") === resource &&
|
||||
params.get("filter[muted]") === "include";
|
||||
return HttpResponse.json({
|
||||
data: matches
|
||||
? [{ id: "r1", attributes: { finding_id: "f1", muted: true } }]
|
||||
: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const screen = await render(await IssuesList({ searchParams: {} }));
|
||||
await screen
|
||||
.getByRole("button", { name: `Expand ${issue.attributes.title}` })
|
||||
.click();
|
||||
const link = screen.getByRole("link", { name: "S3 bucket is public" });
|
||||
await expect.element(link).toBeVisible();
|
||||
|
||||
// When: resolve the same filters used by the Findings page and drill-down.
|
||||
const destination = new URL(
|
||||
link.element().getAttribute("href")!,
|
||||
window.location.origin,
|
||||
);
|
||||
const { filters } = extractFiltersAndQuery(
|
||||
Object.fromEntries(destination.searchParams),
|
||||
);
|
||||
const resolvedFilters = applyDefaultMutedFilter(
|
||||
await resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans: [],
|
||||
loadScan: async (id) => (await getScan(id))?.data,
|
||||
}),
|
||||
);
|
||||
const groups = await getFindingGroups({ filters: resolvedFilters });
|
||||
|
||||
// Then
|
||||
expect(groups.data).toEqual([{ id: "s3_public", type: "finding-groups" }]);
|
||||
expect(destination.searchParams.get("expandedCheckId")).toBe("s3_public");
|
||||
const resources = await getFindingGroupResources({
|
||||
checkId: "s3_public",
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
expect(resources.data).toEqual([
|
||||
{ id: "r1", attributes: { finding_id: "f1", muted: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ width: 375, theme: "light" },
|
||||
{ width: 768, theme: "light" },
|
||||
@@ -159,6 +260,7 @@ describe("Issues responsive layout", () => {
|
||||
await expect
|
||||
.element(screen.getByText("Partly", { exact: true }))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText("Public by design.")).toBeVisible();
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole("img", { name: "Critical finding risk icon" }),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FindingTriageStatus } from "@/types/findings-triage";
|
||||
import type { IssueFinding } from "@/types/issues";
|
||||
|
||||
export interface IssueFindingsProps {
|
||||
/** One entry per finding, with a resource to open in the group drill-down. */
|
||||
findings: IssueFinding[];
|
||||
scanId?: string;
|
||||
/** All the findings the issue folds; the list shows a bounded preview. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IssueTriageProps {
|
||||
id: string;
|
||||
status: FindingTriageStatus;
|
||||
}
|
||||
@@ -50,6 +50,8 @@ const EXTRA_FILTER_LABELS = {
|
||||
"filter[severity]": "Severity",
|
||||
"filter[provider_type]": "Provider type",
|
||||
"filter[provider_alias]": "Account",
|
||||
"filter[triage_status]": "Triage",
|
||||
"filter[triage_status__in]": "Triage",
|
||||
} as const;
|
||||
|
||||
export function VerdictFilter({ summary }: { summary?: VerdictSummary }) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
|
||||
import { TriageStatusBadge } from "./triage-status-badge";
|
||||
|
||||
describe("TriageStatusBadge", () => {
|
||||
it("renders the labeled read-only status used in the finding drawer", () => {
|
||||
// Given / When
|
||||
render(<TriageStatusBadge status={FINDING_TRIAGE_STATUS.REMEDIATING} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Triage:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info");
|
||||
});
|
||||
|
||||
it("omits the label when the surrounding table already names the field", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<TriageStatusBadge
|
||||
status={FINDING_TRIAGE_STATUS.RISK_ACCEPTED}
|
||||
showLabel={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText("Triage:")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Risk Accepted")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TRIAGE_STATUS_TEXT_CLASS } from "@/lib/triage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FINDING_TRIAGE_STATUS_LABELS } from "@/types/findings-triage";
|
||||
|
||||
import type { TriageStatusBadgeProps } from "./types";
|
||||
|
||||
export function TriageStatusBadge({
|
||||
status,
|
||||
showLabel = true,
|
||||
}: TriageStatusBadgeProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{showLabel && (
|
||||
<span className="text-text-neutral-tertiary text-xs">Triage:</span>
|
||||
)}
|
||||
<span
|
||||
className={cn("text-xs font-medium", TRIAGE_STATUS_TEXT_CLASS[status])}
|
||||
>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { TRIAGE_STATUS_TEXT_CLASS } from "@/lib/triage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
MANUAL_PASS_NOTE_REQUIRED_COPY,
|
||||
type FindingTriageModalStatus,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import type { TriageStatusPickerProps } from "./types";
|
||||
|
||||
export function TriageStatusPicker({
|
||||
disabled,
|
||||
size = "sm",
|
||||
value,
|
||||
statusValues,
|
||||
showManualPassTooltip = false,
|
||||
onValueChange,
|
||||
}: TriageStatusPickerProps) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextStatus) => {
|
||||
if (statusValues.includes(nextStatus as FindingTriageModalStatus)) {
|
||||
onValueChange(nextStatus as FindingTriageModalStatus);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Triage status"
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
iconSize="sm"
|
||||
>
|
||||
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[value])}>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[value]}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusValues.map((status) => {
|
||||
const shouldExplainManualPass =
|
||||
showManualPassTooltip && status === FINDING_TRIAGE_STATUS.RESOLVED;
|
||||
return (
|
||||
<SelectItem
|
||||
key={status}
|
||||
value={status}
|
||||
aria-label={FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
>
|
||||
<span
|
||||
className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[status])}
|
||||
>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
</span>
|
||||
{shouldExplainManualPass && (
|
||||
<span
|
||||
className="text-text-neutral-secondary text-xs font-normal normal-case"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{MANUAL_PASS_NOTE_REQUIRED_COPY}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import type { SelectTrigger } from "@/components/shadcn/select/select";
|
||||
import type {
|
||||
FindingTriageModalStatus,
|
||||
FindingTriageStatus,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export type TriageStatusPickerSize = NonNullable<
|
||||
ComponentProps<typeof SelectTrigger>["size"]
|
||||
>;
|
||||
|
||||
export interface TriageStatusPickerProps {
|
||||
disabled: boolean;
|
||||
size?: TriageStatusPickerSize;
|
||||
value: FindingTriageStatus;
|
||||
statusValues: readonly FindingTriageModalStatus[];
|
||||
showManualPassTooltip?: boolean;
|
||||
onValueChange: (status: FindingTriageModalStatus) => void;
|
||||
}
|
||||
|
||||
export interface TriageStatusBadgeProps {
|
||||
status: FindingTriageStatus;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FindingTriageStatus } from "@/types/findings-triage";
|
||||
|
||||
export const TRIAGE_STATUS_TEXT_CLASS = {
|
||||
open: "text-text-error-primary",
|
||||
under_review: "text-text-warning-primary",
|
||||
remediating: "text-bg-data-info",
|
||||
resolved: "text-bg-pass",
|
||||
risk_accepted: "text-bg-pass",
|
||||
false_positive: "text-text-neutral-secondary",
|
||||
reopened: "text-text-error-primary",
|
||||
} as const satisfies Record<FindingTriageStatus, string>;
|
||||
@@ -24,6 +24,9 @@ export const FINDING_TRIAGE_STATUS_LABELS = {
|
||||
[FINDING_TRIAGE_STATUS.REOPENED]: "Reopened",
|
||||
} as const satisfies Record<FindingTriageStatus, string>;
|
||||
|
||||
export const MANUAL_PASS_NOTE_REQUIRED_COPY =
|
||||
"Add a Triage Note explaining why this finding passes.";
|
||||
|
||||
export const MANUAL_PASS_PROVENANCE = "Manually verified" as const;
|
||||
|
||||
export const FINDING_TRIAGE_MANUAL_STATUS_VALUES = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MetaDataProps } from "@/types";
|
||||
import { Severity } from "@/types/components";
|
||||
import { FindingTriageStatus } from "@/types/findings-triage";
|
||||
import { ProviderType } from "@/types/providers";
|
||||
|
||||
/** An issue is a group of findings folded along one attack path. */
|
||||
@@ -50,6 +51,9 @@ export interface IssueAttributes {
|
||||
legs: Record<LegName, LegAssessment>;
|
||||
fix: string;
|
||||
findings_count: number;
|
||||
/** The decision on the path, kept per account and pattern across scans. */
|
||||
triage_status: FindingTriageStatus;
|
||||
notes_count: number;
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -69,9 +73,12 @@ export const ISSUE_FINDINGS_LIMIT = 50;
|
||||
/** One finding-to-resource association from the issue's own scan. */
|
||||
export interface IssueFinding {
|
||||
id: string;
|
||||
uid: string;
|
||||
checkId: string;
|
||||
checkTitle: string;
|
||||
severity: Severity;
|
||||
/** The finding's own triage, which stays where it lives. */
|
||||
triageStatus: FindingTriageStatus;
|
||||
resourceUid: string;
|
||||
}
|
||||
|
||||
@@ -133,3 +140,11 @@ export interface IssuesOverviewResource {
|
||||
export interface IssuesOverviewResponse {
|
||||
data: IssuesOverviewResource;
|
||||
}
|
||||
|
||||
/** One note on an issue's triage, newest first. */
|
||||
export interface IssueTriageNote {
|
||||
id: string;
|
||||
body: string;
|
||||
insertedAt: string;
|
||||
author?: string;
|
||||
}
|
||||
Reference in new issue
Block a user