feat(ui): triage issues and send them to Jira (#13151)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Rubén De la Torre Vicoandalejandrobailo authored and GitHub committed 2026-10-26 15:09:05 +01:00
1 parent 3bbd551f0f
commit 83152ae1a6
32 files changed
+1244 -188

No files matched your search

+28 -16
View File
@@ -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,
};
});
};
+142 -7
View File
@@ -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,
},
]);
});
});
+79 -3
View File
@@ -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;
}
};
+65
View File
@@ -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];
@@ -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();
}
}
}
@@ -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;
@@ -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", () => ({
@@ -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 ? (
+97 -1
View File
@@ -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"],
},
});
});
});
+56 -8
View File
@@ -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&apos;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&apos;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>
);
};
+83
View File
@@ -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",
},
]}
+203
View File
@@ -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();
});
});
+138
View File
@@ -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>
);
};
+9 -1
View File
@@ -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: "",
+9
View File
@@ -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" }),
+15
View File
@@ -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;
}
+2
View File
@@ -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>
);
}
+25
View File
@@ -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;
}
+11
View File
@@ -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>;
+3
View File
@@ -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 = [
+15
View File
@@ -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;
}