mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): add manual pass triage workflow (#12253)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
931612443a
commit
8bfca81e4b
@@ -160,6 +160,57 @@ describe("adaptLatestFindingTriageNote", () => {
|
||||
});
|
||||
|
||||
describe("adaptFindingTriageSummariesResponse", () => {
|
||||
it("should keep Resolved as triage while exposing effective Pass and manual provenance", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "finding-manual-1",
|
||||
type: "findings",
|
||||
attributes: {
|
||||
uid: "prowler-finding-manual-uid-1",
|
||||
status: "PASS",
|
||||
raw_status: "MANUAL",
|
||||
triage_status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
manual_pass_created_at: "2026-06-03T10:00:00Z",
|
||||
manual_pass_expires_at: "2026-09-01T10:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const [summary] = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expect(summary.rawFindingStatus).toBe("MANUAL");
|
||||
expect(input.data[0].attributes.status).toBe("PASS");
|
||||
expect(summary.label).toBe("Resolved");
|
||||
expect(summary.manualPassProvenance).toBe("Manually verified");
|
||||
});
|
||||
|
||||
it("should keep natural Pass free of manual provenance", () => {
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "finding-natural-pass",
|
||||
type: "findings",
|
||||
attributes: {
|
||||
uid: "natural-pass-uid",
|
||||
status: "PASS",
|
||||
raw_status: "PASS",
|
||||
triage_status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [summary] = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
expect(summary.rawFindingStatus).toBe("PASS");
|
||||
expect(summary.manualPassProvenance).toBeNull();
|
||||
});
|
||||
|
||||
it("should return [] when the provisional API response is malformed", () => {
|
||||
// Given
|
||||
const input = { meta: { count: 0 } };
|
||||
@@ -380,6 +431,67 @@ describe("adaptFindingTriageSummariesResponse", () => {
|
||||
});
|
||||
|
||||
describe("adaptFindingTriageDetailResponse", () => {
|
||||
it("should map expired Manual Pass metadata for provenance presentation", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: {
|
||||
id: "triage-expired-1",
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
finding_id: "finding-expired-1",
|
||||
finding_uid: "prowler-finding-expired-uid-1",
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
raw_finding_status: "MANUAL",
|
||||
manual_pass_active: false,
|
||||
manual_pass_evidence: "The control owner verified the requirement.",
|
||||
manual_pass_created_by_name: "Alex Security",
|
||||
manual_pass_created_at: "2026-06-03T10:00:00Z",
|
||||
manual_pass_expires_at: "2026-06-17T10:00:00Z",
|
||||
manual_pass_deactivated_at: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const detail = adaptFindingTriageDetailResponse(input);
|
||||
|
||||
// Then
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
manualPassActive: false,
|
||||
manualPassEvidence: "The control owner verified the requirement.",
|
||||
manualPassCreatedByName: "Alex Security",
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
manualPassDeactivatedAt: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should normalize missing Manual Pass evidence without inventing content", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: {
|
||||
id: "triage-without-evidence",
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
manual_pass_active: false,
|
||||
manual_pass_created_at: "2026-06-03T10:00:00Z",
|
||||
manual_pass_expires_at: "2026-06-17T10:00:00Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const detail = adaptFindingTriageDetailResponse(input);
|
||||
|
||||
// Then
|
||||
expect(detail.manualPassEvidence).toBeNull();
|
||||
expect(detail.manualPassDeactivatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should normalize provisional detail payloads into modal DTOs", () => {
|
||||
// Given
|
||||
const input = findingTriageDetailResponse;
|
||||
@@ -395,6 +507,10 @@ describe("adaptFindingTriageDetailResponse", () => {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
rawFindingStatus: "MANUAL",
|
||||
manualPassCreatedByName: "Alex Security",
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
label: "Risk Accepted",
|
||||
hasVisibleNote: true,
|
||||
canEdit: true,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { FINDING_STATUS, type FindingStatus } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_BILLING_HREF,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
MANUAL_PASS_PROVENANCE,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageDisabledReason,
|
||||
type FindingTriageLoadedNote,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageSummary,
|
||||
type FindingTriageUpdateResult,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
// API/backend triage implementation is external to this UI slice. Keep final
|
||||
@@ -25,6 +28,7 @@ interface FindingTriageAttributes {
|
||||
uid?: string;
|
||||
triage_id?: string;
|
||||
triage_notes_count?: number;
|
||||
notes_count?: number;
|
||||
triage_status?: unknown;
|
||||
triage_has_note?: boolean;
|
||||
status?: unknown;
|
||||
@@ -34,6 +38,14 @@ interface FindingTriageAttributes {
|
||||
note?: string;
|
||||
has_note?: boolean;
|
||||
note_id?: string;
|
||||
raw_finding_status?: unknown;
|
||||
raw_status?: unknown;
|
||||
manual_pass_active?: unknown;
|
||||
manual_pass_evidence?: unknown;
|
||||
manual_pass_created_by_name?: unknown;
|
||||
manual_pass_created_at?: unknown;
|
||||
manual_pass_expires_at?: unknown;
|
||||
manual_pass_deactivated_at?: unknown;
|
||||
}
|
||||
|
||||
interface JsonApiResource {
|
||||
@@ -59,10 +71,14 @@ const isFindingTriageStatus = (value: unknown): value is FindingTriageStatus =>
|
||||
typeof value === "string" &&
|
||||
Object.values(FINDING_TRIAGE_STATUS).includes(value as FindingTriageStatus);
|
||||
|
||||
const isRawFindingStatus = (value: unknown): value is FindingStatus =>
|
||||
typeof value === "string" &&
|
||||
Object.values(FINDING_STATUS).includes(value as FindingStatus);
|
||||
|
||||
const fallbackStatusFromFindingStatus = (
|
||||
findingStatus: unknown,
|
||||
): FindingTriageStatus =>
|
||||
findingStatus === "PASS"
|
||||
findingStatus === FINDING_STATUS.PASS
|
||||
? FINDING_TRIAGE_STATUS.RESOLVED
|
||||
: FINDING_TRIAGE_STATUS.OPEN;
|
||||
|
||||
@@ -93,6 +109,13 @@ const createSummary = (
|
||||
options: FindingTriageAdapterOptions,
|
||||
): FindingTriageSummary => {
|
||||
const attributes = finding.attributes ?? {};
|
||||
const rawFindingStatus = isRawFindingStatus(attributes.raw_finding_status)
|
||||
? attributes.raw_finding_status
|
||||
: isRawFindingStatus(attributes.raw_status)
|
||||
? attributes.raw_status
|
||||
: isRawFindingStatus(attributes.status)
|
||||
? attributes.status
|
||||
: null;
|
||||
const summary: FindingTriageSummary = {
|
||||
findingId: attributes.finding_id || finding.id || "",
|
||||
findingUid: attributes.uid || attributes.finding_uid || "",
|
||||
@@ -105,6 +128,13 @@ const createSummary = (
|
||||
typeof attributes.muted === "boolean"
|
||||
? attributes.muted
|
||||
: attributes.status === "MUTED",
|
||||
rawFindingStatus,
|
||||
manualPassProvenance:
|
||||
attributes.status === FINDING_STATUS.PASS &&
|
||||
rawFindingStatus === FINDING_STATUS.MANUAL &&
|
||||
triageFields.status === FINDING_TRIAGE_STATUS.RESOLVED
|
||||
? MANUAL_PASS_PROVENANCE
|
||||
: null,
|
||||
canEdit: options.canEdit ?? false,
|
||||
billingHref: options.billingHref ?? FINDING_TRIAGE_BILLING_HREF,
|
||||
};
|
||||
@@ -192,19 +222,24 @@ export function adaptFindingTriageDetailResponse(
|
||||
: typeof attributes.note === "string"
|
||||
? attributes.note
|
||||
: "";
|
||||
const notesCount =
|
||||
attributes.triage_notes_count ??
|
||||
attributes.notes_count ??
|
||||
(noteBody.length > 0 ? 1 : 0);
|
||||
const summary = createSummary(
|
||||
{
|
||||
id: attributes.finding_id || data?.id || "",
|
||||
attributes: {
|
||||
finding_uid: attributes.finding_uid || "",
|
||||
triage_id: data?.id,
|
||||
triage_notes_count:
|
||||
attributes.triage_notes_count ?? (noteBody.length > 0 ? 1 : 0),
|
||||
triage_notes_count: notesCount,
|
||||
raw_finding_status: attributes.raw_finding_status,
|
||||
},
|
||||
},
|
||||
{
|
||||
status,
|
||||
hasVisibleNote: attributes.has_note === true || noteBody.length > 0,
|
||||
hasVisibleNote:
|
||||
attributes.has_note === true || notesCount > 0 || noteBody.length > 0,
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -214,5 +249,46 @@ export function adaptFindingTriageDetailResponse(
|
||||
noteId: attributes.note_id || null,
|
||||
noteBody,
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
rawFindingStatus: isRawFindingStatus(attributes.raw_finding_status)
|
||||
? attributes.raw_finding_status
|
||||
: null,
|
||||
manualPassActive:
|
||||
typeof attributes.manual_pass_active === "boolean"
|
||||
? attributes.manual_pass_active
|
||||
: null,
|
||||
manualPassEvidence:
|
||||
typeof attributes.manual_pass_evidence === "string"
|
||||
? attributes.manual_pass_evidence
|
||||
: null,
|
||||
manualPassCreatedByName:
|
||||
typeof attributes.manual_pass_created_by_name === "string"
|
||||
? attributes.manual_pass_created_by_name
|
||||
: null,
|
||||
manualPassCreatedAt:
|
||||
typeof attributes.manual_pass_created_at === "string"
|
||||
? attributes.manual_pass_created_at
|
||||
: null,
|
||||
manualPassExpiresAt:
|
||||
typeof attributes.manual_pass_expires_at === "string"
|
||||
? attributes.manual_pass_expires_at
|
||||
: null,
|
||||
manualPassDeactivatedAt:
|
||||
typeof attributes.manual_pass_deactivated_at === "string"
|
||||
? attributes.manual_pass_deactivated_at
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptFindingTriageUpdateResponse(
|
||||
apiResponse: unknown,
|
||||
): FindingTriageUpdateResult {
|
||||
const data =
|
||||
isRecord(apiResponse) && isJsonApiResource(apiResponse.data)
|
||||
? apiResponse.data
|
||||
: undefined;
|
||||
const expiresAt = data?.attributes?.manual_pass_expires_at;
|
||||
|
||||
return {
|
||||
manualPassExpiresAt: typeof expiresAt === "string" ? expiresAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +88,10 @@ export const findingTriageDetailResponse = {
|
||||
finding_id: "finding-1",
|
||||
finding_uid: "prowler-finding-uid-1",
|
||||
status: "risk_accepted",
|
||||
raw_finding_status: "MANUAL",
|
||||
manual_pass_created_by_name: "Alex Security",
|
||||
manual_pass_created_at: "2026-06-03T10:00:00Z",
|
||||
manual_pass_expires_at: "2026-06-17T10:00:00Z",
|
||||
triage_notes_count: 1,
|
||||
has_note: true,
|
||||
note_id: "note-detail-1",
|
||||
|
||||
@@ -7,11 +7,17 @@ const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiResponseMock,
|
||||
revalidatePathMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createMuteRuleMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
revalidatePathMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: revalidatePathMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/mute-rules", () => ({
|
||||
@@ -38,6 +44,75 @@ describe("findings triage actions", () => {
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
it("should load authoritative manual pass context through the finding UID route", async () => {
|
||||
// Given
|
||||
const { loadFindingTriageDetail } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: {
|
||||
id: "triage-1",
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
finding_uid: "finding/stable/uid",
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
raw_finding_status: "MANUAL",
|
||||
notes_count: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await loadFindingTriageDetail({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
rawFindingStatus: "MANUAL",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject API errors when loading triage detail", async () => {
|
||||
// Given
|
||||
const { loadFindingTriageDetail } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
status: 403,
|
||||
errors: [{ detail: "Finding triage access denied." }],
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
loadFindingTriageDetail({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
}),
|
||||
).rejects.toThrow("Finding triage access denied.");
|
||||
});
|
||||
|
||||
it("should load notes through the persisted triage route when triageId exists", async () => {
|
||||
// Given
|
||||
const { loadLatestFindingTriageNote } = await importActions();
|
||||
@@ -185,6 +260,127 @@ describe("findings triage actions", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(revalidatePathMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send manual pass status and fresh evidence in one triage request", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: {
|
||||
id: "triage-1",
|
||||
attributes: {
|
||||
manual_pass_expires_at: "2026-10-28T12:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "Fresh control-owner evidence.",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
manualPassExpiresAt: "2026-10-28T12:00:00Z",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/finding-triages/triage-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
note: "Fresh control-owner evidence.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/findings");
|
||||
});
|
||||
|
||||
it("should accept manual pass evidence at the maximum length", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
const manualPassEvidence = "a".repeat(500);
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/finding-triages/triage-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: expect.stringContaining(manualPassEvidence),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject manual pass evidence over the maximum length", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "a".repeat(501),
|
||||
}),
|
||||
).rejects.toThrow("Manual pass evidence cannot exceed 500 characters.");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should surface JSON:API validation details when the manual pass patch fails", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
errors: [
|
||||
{
|
||||
detail: "Manual pass evidence must describe the verification.",
|
||||
source: { pointer: "/data/attributes/note" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "Fresh control-owner evidence.",
|
||||
}),
|
||||
).rejects.toThrow("Manual pass evidence must describe the verification.");
|
||||
});
|
||||
|
||||
it("should update an existing note through its note id", async () => {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { adaptLatestFindingTriageNote } from "@/actions/findings/findings-triage.adapter";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import {
|
||||
adaptFindingTriageUpdateResponse,
|
||||
adaptFindingTriageDetailResponse,
|
||||
adaptLatestFindingTriageNote,
|
||||
} from "@/actions/findings/findings-triage.adapter";
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageLoadedNote,
|
||||
type FindingTriageSummary,
|
||||
isMutelistShortcutStatus,
|
||||
@@ -65,15 +74,28 @@ const throwIfApiError = (result: unknown) => {
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
("error" in result ||
|
||||
("errors" in result && Array.isArray(result.errors)) ||
|
||||
("status" in result &&
|
||||
typeof result.status === "number" &&
|
||||
result.status >= 400))
|
||||
) {
|
||||
throw new Error(
|
||||
"error" in result && typeof result.error === "string"
|
||||
const jsonApiDetail =
|
||||
"errors" in result && Array.isArray(result.errors)
|
||||
? result.errors.find((error): error is { detail: string } =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"detail" in error &&
|
||||
typeof error.detail === "string",
|
||||
),
|
||||
)?.detail
|
||||
: undefined;
|
||||
const message =
|
||||
("error" in result && typeof result.error === "string"
|
||||
? result.error
|
||||
: "Finding triage request failed.",
|
||||
);
|
||||
: jsonApiDetail) || "Finding triage request failed.";
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,6 +247,28 @@ export async function loadLatestFindingTriageNote(
|
||||
return latestNote;
|
||||
}
|
||||
|
||||
export async function loadFindingTriageDetail(
|
||||
triage: FindingTriageSummary,
|
||||
): Promise<FindingTriageDetail> {
|
||||
const findingUid = triage.findingUid || (await resolveFindingUid(triage));
|
||||
const apiResponse = await getJsonApi(
|
||||
`/findings/${encodePathSegment(findingUid)}/triage`,
|
||||
);
|
||||
throwIfApiError(apiResponse);
|
||||
const detail = adaptFindingTriageDetailResponse(apiResponse, {
|
||||
canEdit: triage.canEdit,
|
||||
disabledReason: triage.disabledReason,
|
||||
billingHref: triage.billingHref,
|
||||
});
|
||||
|
||||
return {
|
||||
...detail,
|
||||
findingId: triage.findingId,
|
||||
findingUid,
|
||||
isMuted: triage.isMuted,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
|
||||
let findingUid: string | undefined;
|
||||
let triagePath: `/${string}`;
|
||||
@@ -236,6 +280,29 @@ export async function updateFindingTriage(input: UpdateFindingTriageInput) {
|
||||
triagePath = `/findings/${encodePathSegment(findingUid)}/triage`;
|
||||
}
|
||||
|
||||
if (input.status === FINDING_TRIAGE_STATUS.RESOLVED) {
|
||||
const evidence = input.manualPassEvidence?.trim();
|
||||
if (!evidence) {
|
||||
throw new Error("Fresh evidence is required to pass a MANUAL finding.");
|
||||
}
|
||||
if (evidence.length > FINDING_TRIAGE_NOTE_MAX_LENGTH) {
|
||||
throw new Error(
|
||||
`Manual pass evidence cannot exceed ${FINDING_TRIAGE_NOTE_MAX_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await patchJsonApi(
|
||||
triagePath,
|
||||
buildFindingTriageBody({
|
||||
status: input.status,
|
||||
note: evidence,
|
||||
}),
|
||||
);
|
||||
|
||||
revalidatePath("/findings");
|
||||
return adaptFindingTriageUpdateResponse(result);
|
||||
}
|
||||
|
||||
if (input.note !== undefined && input.notesCount > 0 && input.noteId) {
|
||||
const notePath: `/${string}` = `${triagePath}/notes/${input.noteId}`;
|
||||
const noteResult =
|
||||
|
||||
@@ -9,8 +9,8 @@ describe("findings view overview SSR", () => {
|
||||
const filePath = path.join(currentDir, "findings-view.ssr.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("uses the non-legacy latest findings columns", () => {
|
||||
expect(source).toContain("ColumnLatestFindings");
|
||||
it("uses the triage-aware latest findings table", () => {
|
||||
expect(source).toContain("LatestFindingsTable");
|
||||
expect(source).not.toContain("ColumnNewFindingsToDate");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import { getLatestFindings } from "@/actions/findings/findings";
|
||||
import { LinkToFindings } from "@/components/overview";
|
||||
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
|
||||
import { LatestFindingsTable } from "@/components/overview/new-findings-table/table";
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import { DataTable } from "@/components/shadcn/table";
|
||||
import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { createDict } from "@/lib/utils";
|
||||
import { FindingProps, SearchParamsProps } from "@/types";
|
||||
@@ -61,9 +60,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<DataTable
|
||||
key={`dashboard-findings-${Date.now()}`}
|
||||
columns={ColumnLatestFindings}
|
||||
<LatestFindingsTable
|
||||
data={(expandedResponse?.data || []) as FindingProps[]}
|
||||
header={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Manual verification workflow for `MANUAL` findings with evidence, effective `PASS` status, and expiration details
|
||||
@@ -4,6 +4,7 @@ import { AlertTriangle } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
loadFindingTriageDetail,
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
@@ -81,16 +82,17 @@ export const ClientAccordionContent = ({
|
||||
});
|
||||
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
const result = await updateFindingTriage(input);
|
||||
|
||||
// Mutelist-shortcut statuses mute the finding server-side; refetch so the
|
||||
// list honors the muted filter, matching the resource drawer behavior.
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
reload();
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
patchTriageUpdate(input);
|
||||
return result;
|
||||
};
|
||||
|
||||
const renderDetails = () => {
|
||||
@@ -180,6 +182,7 @@ export const ClientAccordionContent = ({
|
||||
openFindingId,
|
||||
onTriageUpdateAction: handleTriageUpdate,
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
onTriageDetailLoadAction: loadFindingTriageDetail,
|
||||
})}
|
||||
data={expandedFindings}
|
||||
metadata={findings?.meta}
|
||||
|
||||
@@ -301,19 +301,22 @@ describe("useRequirementFindings", () => {
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
manualPassEvidence: "Verified by the control owner.",
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.expandedFindings[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
}),
|
||||
);
|
||||
expect(result.current.expandedFindings[0]?.attributes.status).toBe("PASS");
|
||||
});
|
||||
|
||||
it("should ignore stale responses after the query changes", async () => {
|
||||
|
||||
@@ -314,6 +314,25 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
expect(columnIds.at(-1)).toBe("actions");
|
||||
});
|
||||
|
||||
it("should not expose the manual-only Resolved path on group-level rows", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown: vi.fn(),
|
||||
});
|
||||
|
||||
// When
|
||||
const columnIds = columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(columnIds).not.toContain("triage");
|
||||
});
|
||||
|
||||
it("should render the first provider icon with its provider name", () => {
|
||||
// Given
|
||||
renderFindingGroupTitleCell({ providers: ["iac"] });
|
||||
|
||||
@@ -24,8 +24,10 @@ import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributi
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
FindingTriageContext,
|
||||
FindingTriageDetailLoadHandler,
|
||||
FindingTriageNoteLoadHandler,
|
||||
FindingTriageUpdateHandler,
|
||||
} from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusCell,
|
||||
} from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
LighthouseSkillsRowButton,
|
||||
@@ -47,6 +48,16 @@ import {
|
||||
NotificationIndicator,
|
||||
} from "./notification-indicator";
|
||||
|
||||
const buildFindingResourceTriageContext = (
|
||||
resource: FindingResourceRow,
|
||||
findingTitle?: string,
|
||||
): FindingTriageContext => ({
|
||||
title: findingTitle || resource.checkId,
|
||||
resource: resource.resourceName,
|
||||
provider: resource.providerAlias,
|
||||
providerType: resource.providerType,
|
||||
});
|
||||
|
||||
// One finding-context item per resource row, shared by the leading Skills
|
||||
// pill and the ⋮ submenu so both launch with identical context.
|
||||
const buildResourceFindingItem = (resource: FindingResourceRow) =>
|
||||
@@ -66,14 +77,14 @@ const ResourceRowActions = ({
|
||||
onSkillLaunchOpenDrawer,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
onTriageDetailLoadAction,
|
||||
}: {
|
||||
row: Row<FindingResourceRow>;
|
||||
findingTitle?: string;
|
||||
onSkillLaunchOpenDrawer?: (rowIndex: number) => void;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
onTriageNoteLoadAction?: FindingTriageNoteLoadHandler;
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}) => {
|
||||
const resource = row.original;
|
||||
const canMute = canMuteFindingResource(resource);
|
||||
@@ -160,14 +171,13 @@ const ResourceRowActions = ({
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<FindingNoteActionItem
|
||||
triage={resource.triage}
|
||||
findingContext={{
|
||||
title: findingTitle || resource.checkId,
|
||||
resource: resource.resourceName,
|
||||
provider: resource.providerAlias,
|
||||
providerType: resource.providerType,
|
||||
}}
|
||||
findingContext={buildFindingResourceTriageContext(
|
||||
resource,
|
||||
findingTitle,
|
||||
)}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
@@ -215,9 +225,8 @@ interface GetColumnFindingResourcesOptions {
|
||||
// the chat tab, so the run and the finding share the side panel.
|
||||
onSkillLaunchOpenDrawer?: (rowIndex: number) => void;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
onTriageNoteLoadAction?: FindingTriageNoteLoadHandler;
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}
|
||||
|
||||
export function getColumnFindingResources({
|
||||
@@ -227,6 +236,7 @@ export function getColumnFindingResources({
|
||||
onSkillLaunchOpenDrawer,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
onTriageDetailLoadAction,
|
||||
}: GetColumnFindingResourcesOptions): ColumnDef<FindingResourceRow>[] {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
@@ -406,7 +416,12 @@ export function getColumnFindingResources({
|
||||
<InfoField label="Triage" variant="compact">
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
findingContext={buildFindingResourceTriageContext(
|
||||
row.original,
|
||||
findingTitle,
|
||||
)}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
</InfoField>
|
||||
),
|
||||
@@ -424,6 +439,7 @@ export function getColumnFindingResources({
|
||||
onSkillLaunchOpenDrawer={onSkillLaunchOpenDrawer}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
|
||||
@@ -18,14 +18,14 @@ import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import { FindingProps, ProviderType } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
FindingTriageDetailLoadHandler,
|
||||
FindingTriageNoteLoadHandler,
|
||||
FindingTriageUpdateHandler,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { FindingDetailDrawer } from "./finding-detail-drawer";
|
||||
import { FindingTriageStatusCell } from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
@@ -33,9 +33,8 @@ interface GetStandaloneFindingColumnsOptions {
|
||||
includeUpdatedAt?: boolean;
|
||||
openFindingId?: string | null;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
onTriageNoteLoadAction?: FindingTriageNoteLoadHandler;
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}
|
||||
|
||||
const getFindingsData = (row: { original: FindingProps }) => {
|
||||
@@ -60,6 +59,15 @@ const getProviderData = (
|
||||
return row.original.relationships?.provider?.attributes?.[field] || "-";
|
||||
};
|
||||
|
||||
const buildFindingContext = (row: { original: FindingProps }) => ({
|
||||
title: row.original.attributes.check_metadata.checktitle,
|
||||
resource: getOptionalText(getResourceData(row, "name")),
|
||||
provider: getOptionalText(getProviderData(row, "alias")),
|
||||
providerType: getOptionalText(getProviderData(row, "provider")) as
|
||||
| ProviderType
|
||||
| undefined,
|
||||
});
|
||||
|
||||
function FindingTitleCell({
|
||||
finding,
|
||||
defaultOpen = false,
|
||||
@@ -93,6 +101,7 @@ export function getStandaloneFindingColumns({
|
||||
openFindingId = null,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
onTriageDetailLoadAction,
|
||||
}: GetStandaloneFindingColumnsOptions = {}): ColumnDef<FindingProps>[] {
|
||||
const columns: ColumnDef<FindingProps>[] = [
|
||||
{
|
||||
@@ -295,7 +304,9 @@ export function getStandaloneFindingColumns({
|
||||
cell: ({ row }) => (
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
findingContext={buildFindingContext(row)}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -304,27 +315,15 @@ export function getStandaloneFindingColumns({
|
||||
id: "actions",
|
||||
size: 56,
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => {
|
||||
const resourceName = getResourceData(row, "name");
|
||||
const providerAlias = getProviderData(row, "alias");
|
||||
const providerType = getProviderData(row, "provider");
|
||||
|
||||
return (
|
||||
<DataTableRowActions
|
||||
row={row}
|
||||
findingContext={{
|
||||
title: row.original.attributes.check_metadata.checktitle,
|
||||
resource: getOptionalText(resourceName),
|
||||
provider: getOptionalText(providerAlias),
|
||||
providerType: getOptionalText(providerType) as
|
||||
| ProviderType
|
||||
| undefined,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<DataTableRowActions
|
||||
row={row}
|
||||
findingContext={buildFindingContext(row)}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,17 +19,18 @@ import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributi
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageContext,
|
||||
FindingTriageDetailLoadHandler,
|
||||
FindingTriageNoteLoadHandler,
|
||||
FindingTriageSummary,
|
||||
FindingTriageUpdateHandler,
|
||||
} from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import type { FindingTriageContext } from "./finding-note-modal";
|
||||
import { FindingNoteActionItem } from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
LighthouseSkillsSubmenu,
|
||||
@@ -107,9 +108,8 @@ interface DataTableRowActionsProps<T extends FindingRowData> {
|
||||
onMuteComplete?: (findingIds: string[]) => void;
|
||||
findingContext?: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
onTriageNoteLoadAction?: FindingTriageNoteLoadHandler;
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<T extends FindingRowData>({
|
||||
@@ -118,6 +118,7 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
onTriageDetailLoadAction,
|
||||
}: DataTableRowActionsProps<T>) {
|
||||
const router = useRouter();
|
||||
const finding = row.original;
|
||||
@@ -281,6 +282,7 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
findingContext={resolvedFindingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/components/shadcn")>()),
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
ProviderTypeIcon: ({ type }: { type: string }) => (
|
||||
@@ -21,20 +38,32 @@ vi.mock("@/components/shadcn/modal", () => ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
onOpenAutoFocus,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
onOpenAutoFocus?: (event: Event) => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
onOpenAutoFocus?.(new Event("openAutoFocus"));
|
||||
}
|
||||
}, [onOpenAutoFocus, open]);
|
||||
|
||||
return open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
) : null;
|
||||
},
|
||||
}));
|
||||
|
||||
const originalTimezone = process.env.TZ;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "UTC";
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
@@ -49,19 +78,31 @@ beforeAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalTimezone === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = originalTimezone;
|
||||
}
|
||||
});
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type UpdateFindingTriageInput,
|
||||
type FindingTriageContext,
|
||||
type FindingTriageModalStatus,
|
||||
type FindingTriageUpdateHandler,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FINDING_NOTE_MODAL_MODE,
|
||||
FindingNoteModal,
|
||||
type FindingTriageContext,
|
||||
type FindingNoteModalMode,
|
||||
} from "./finding-note-modal";
|
||||
|
||||
function makeTriageDetail(
|
||||
@@ -81,11 +122,20 @@ function makeTriageDetail(
|
||||
noteId: "note-1",
|
||||
noteBody: "Existing investigation note",
|
||||
maxNoteLength: 500,
|
||||
rawFindingStatus: FINDING_STATUS.FAIL,
|
||||
manualPassActive: null,
|
||||
manualPassEvidence: null,
|
||||
manualPassCreatedByName: null,
|
||||
manualPassCreatedAt: null,
|
||||
manualPassExpiresAt: null,
|
||||
manualPassDeactivatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
});
|
||||
|
||||
@@ -98,11 +148,15 @@ function renderNoteModal({
|
||||
resource: "production-bucket",
|
||||
provider: "production-account",
|
||||
},
|
||||
mode,
|
||||
initialStatus,
|
||||
}: {
|
||||
triage?: FindingTriageDetail;
|
||||
onTriageUpdateAction?: (input: UpdateFindingTriageInput) => void;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
findingContext?: FindingTriageContext;
|
||||
mode?: FindingNoteModalMode;
|
||||
initialStatus?: FindingTriageModalStatus;
|
||||
} = {}) {
|
||||
render(
|
||||
<FindingNoteModal
|
||||
@@ -110,6 +164,8 @@ function renderNoteModal({
|
||||
onOpenChange={onOpenChange}
|
||||
triage={triage}
|
||||
findingContext={findingContext}
|
||||
mode={mode}
|
||||
initialStatus={initialStatus}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
@@ -145,7 +201,9 @@ describe("FindingNoteModal", () => {
|
||||
// Then
|
||||
const dialog = screen.getByRole("dialog", { name: "Add Triage Note" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("S3 bucket allows public reads"));
|
||||
expect(
|
||||
within(dialog).getByText("S3 bucket allows public reads"),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(dialog).getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
@@ -255,7 +313,11 @@ describe("FindingNoteModal", () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const onTriageUpdateAction = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("Manual pass evidence must describe the verification."),
|
||||
);
|
||||
renderNoteModal({ onOpenChange, onTriageUpdateAction });
|
||||
|
||||
// When
|
||||
@@ -265,12 +327,54 @@ describe("FindingNoteModal", () => {
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Could not update the note. Please try again."),
|
||||
await screen.findByText(
|
||||
"Manual pass evidence must describe the verification.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeInTheDocument();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should expose a pending manual pass submission and keep controls disabled", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveUpdate: () => void = () => {};
|
||||
const onTriageUpdateAction = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
}),
|
||||
);
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
}),
|
||||
onTriageUpdateAction,
|
||||
});
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
await user.type(
|
||||
screen.getByLabelText("Manual pass evidence"),
|
||||
"Verified by the control owner.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
|
||||
expect(screen.getByLabelText("Manual pass evidence")).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toBeDisabled();
|
||||
|
||||
resolveUpdate();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Saving...")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should lock the status picker for resolved findings while keeping the note editable", async () => {
|
||||
@@ -403,4 +507,393 @@ describe("FindingNoteModal", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should offer Resolved only for an authoritative MANUAL finding without duration controls", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
}),
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("option", { name: "Resolved" })).toBeVisible();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Manual pass evidence")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Manual pass evidence")).toBeRequired();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should label, explain, and focus required manual pass evidence", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
}),
|
||||
initialStatus: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
});
|
||||
|
||||
// Then
|
||||
const evidence = screen.getByRole("textbox", {
|
||||
name: "Manual pass evidence",
|
||||
});
|
||||
expect(evidence).toHaveFocus();
|
||||
expect(evidence).toHaveAccessibleDescription(
|
||||
"Add a Triage Note explaining why this finding passes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not offer Resolved when the authoritative raw finding status is not MANUAL", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.FAIL,
|
||||
}),
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("option", { name: "Resolved" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should submit Resolved with fresh evidence instead of the editable note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
}),
|
||||
onTriageUpdateAction,
|
||||
});
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByLabelText("Manual pass evidence"),
|
||||
"The control owner verified this requirement.",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "The control owner verified this requirement.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear manual pass evidence when the modal closes before reopening", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const triage = makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const onOpenChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<FindingNoteModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triage={triage}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
await user.type(
|
||||
screen.getByLabelText("Manual pass evidence"),
|
||||
"Evidence that must not leak.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
rerender(
|
||||
<FindingNoteModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triage={triage}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Note text")).toHaveValue(
|
||||
"Existing investigation note",
|
||||
);
|
||||
expect(screen.queryByLabelText("Manual pass evidence")).toBeNull();
|
||||
});
|
||||
|
||||
it("should reset manual pass evidence when an unchanged update closes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const triage = makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const onOpenChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<FindingNoteModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triage={triage}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Manual pass evidence" }),
|
||||
"Evidence that must not leak.",
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Under Review" }));
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
rerender(
|
||||
<FindingNoteModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triage={triage}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Manual pass evidence" }),
|
||||
).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should show one manual pass success toast with the authoritative mutation expiry", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const triage = makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const onTriageUpdateAction = vi.fn().mockResolvedValue({
|
||||
manualPassExpiresAt: "2026-10-28T12:00:00Z",
|
||||
});
|
||||
renderNoteModal({ triage, onTriageUpdateAction });
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
await user.type(
|
||||
screen.getByLabelText("Manual pass evidence"),
|
||||
"The control owner verified this requirement.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(toastMock).toHaveBeenCalledTimes(1));
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Finding manually verified as Pass",
|
||||
description: "Triage: Resolved · Valid until Oct 28, 2026",
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit an invalid manual pass expiry from the success toast", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn().mockResolvedValue({
|
||||
manualPassExpiresAt: "invalid-expiry",
|
||||
});
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
}),
|
||||
onTriageUpdateAction,
|
||||
});
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Manual pass evidence" }),
|
||||
"The control owner verified this requirement.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(toastMock).toHaveBeenCalledTimes(1));
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
title: "Finding manually verified as Pass",
|
||||
description: "Triage: Resolved",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show the manual pass toast for an ordinary update", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderNoteModal({ onTriageUpdateAction: vi.fn() });
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Documented owner follow-up.");
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Saving...")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show existing manual pass provenance and preserve note editing", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
manualPassActive: true,
|
||||
manualPassEvidence: "The active control evidence was reviewed.",
|
||||
manualPassCreatedByName: "Alex Security",
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText(/Manually verified by Alex Security/i),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("Jun 03, 2026")).toBeVisible();
|
||||
expect(screen.getByText("Jun 17, 2026")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("The active control evidence was reviewed."),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Expired")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Note text")).toHaveValue(
|
||||
"Existing investigation note",
|
||||
);
|
||||
expect(screen.queryByLabelText("Manual pass evidence")).toBeNull();
|
||||
});
|
||||
|
||||
it("should show expired Manual Pass provenance separately from required renewal evidence", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
manualPassActive: false,
|
||||
manualPassEvidence: "The control owner verified the requirement.",
|
||||
manualPassCreatedByName: "Alex Security",
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
manualPassDeactivatedAt: null,
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText(/Previous Manual Pass by Alex Security/i),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("Expired")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("The control owner verified the requirement."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByDisplayValue("The control owner verified the requirement."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Jun 03, 2026")).toBeVisible();
|
||||
expect(screen.getByText("Jun 17, 2026")).toBeVisible();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Manual pass evidence")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Manual pass evidence")).toBeRequired();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("The control owner verified the requirement."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("should show expired provenance safely when prior evidence is missing", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
manualPassActive: false,
|
||||
manualPassEvidence: null,
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/Previous Manual Pass/i)).toBeVisible();
|
||||
expect(screen.getByText("Expired")).toBeVisible();
|
||||
expect(screen.queryByText("Evidence")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show inactive Manual Pass details without edit or submit controls", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
mode: FINDING_NOTE_MODAL_MODE.MANUAL_PASS_DETAILS,
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
manualPassActive: false,
|
||||
manualPassEvidence: null,
|
||||
manualPassCreatedByName: "Alex Security",
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
manualPassDeactivatedAt: "2026-06-10T10:00:00Z",
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
const dialog = screen.getByRole("dialog", { name: "Manual Pass Details" });
|
||||
expect(within(dialog).getByText("Inactive")).toBeVisible();
|
||||
expect(within(dialog).getByText("Inactive on")).toBeVisible();
|
||||
expect(within(dialog).getByText("Jun 10, 2026")).toBeVisible();
|
||||
expect(within(dialog).queryByText("Evidence")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole("combobox", { name: "Triage status" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
within(dialog).queryByLabelText("Note text"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByLabelText("Manual pass evidence"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("button", { name: "Close" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,50 +10,61 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Textarea,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { DateWithTime } from "@/components/shadcn/entities";
|
||||
import { Field, FieldLabel } from "@/components/shadcn/field/field";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { formatLocalDate } from "@/lib/date-utils";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
MANUAL_PASS_PROVENANCE,
|
||||
type FindingTriageContext,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageModalStatus,
|
||||
type FindingTriageUpdateHandler,
|
||||
getFindingTriageMuteInfoCopy,
|
||||
isManualStatus,
|
||||
isMutelistShortcutStatus,
|
||||
isTriageStatusLocked,
|
||||
} from "@/types/findings-triage";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
type FindingTriageUpdateHandler,
|
||||
MANUAL_PASS_NOTE_REQUIRED_COPY,
|
||||
} from "./finding-triage-status-control";
|
||||
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
|
||||
|
||||
export interface FindingTriageContext {
|
||||
title: string;
|
||||
resource?: string;
|
||||
provider?: string;
|
||||
providerType?: ProviderType;
|
||||
}
|
||||
|
||||
interface FindingNoteModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triage: FindingTriageDetail;
|
||||
findingContext: FindingTriageContext;
|
||||
mode?: FindingNoteModalMode;
|
||||
initialStatus?: FindingTriageModalStatus;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
}
|
||||
|
||||
export const FINDING_NOTE_MODAL_MODE = {
|
||||
EDIT: "edit",
|
||||
MANUAL_PASS_DETAILS: "manual-pass-details",
|
||||
} as const;
|
||||
|
||||
export type FindingNoteModalMode =
|
||||
(typeof FINDING_NOTE_MODAL_MODE)[keyof typeof FINDING_NOTE_MODAL_MODE];
|
||||
|
||||
const REMEDIATING_INFO_COPY =
|
||||
"Once this finding is remediated, if in the following scan its status changes to Pass, it will be automatically changed to Resolved";
|
||||
|
||||
@@ -62,31 +73,63 @@ export function FindingNoteModal({
|
||||
onOpenChange,
|
||||
triage,
|
||||
findingContext,
|
||||
mode = FINDING_NOTE_MODAL_MODE.EDIT,
|
||||
initialStatus,
|
||||
onTriageUpdateAction,
|
||||
}: FindingNoteModalProps) {
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
const { toast } = useToast();
|
||||
const initialSelectedStatus =
|
||||
initialStatus ??
|
||||
(triage.status === FINDING_TRIAGE_STATUS.RESOLVED ||
|
||||
isManualStatus(triage.status)
|
||||
? triage.status
|
||||
: FINDING_TRIAGE_STATUS.OPEN);
|
||||
// Local state needed: modal edits are buffered until the user chooses Update.
|
||||
const [selectedStatus, setSelectedStatus] = useState<FindingTriageStatus>(
|
||||
triage.status,
|
||||
);
|
||||
const [selectedStatus, setSelectedStatus] =
|
||||
useState<FindingTriageModalStatus>(initialSelectedStatus);
|
||||
const [note, setNote] = useState(triage.noteBody);
|
||||
const [manualPassEvidence, setManualPassEvidence] = useState("");
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const noteTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isManualPassDetails =
|
||||
mode === FINDING_NOTE_MODAL_MODE.MANUAL_PASS_DETAILS;
|
||||
const canEdit =
|
||||
!isManualPassDetails &&
|
||||
triage.canEdit &&
|
||||
Boolean(onTriageUpdateAction) &&
|
||||
!isSubmitting;
|
||||
const isManualPassSelected =
|
||||
selectedStatus === FINDING_TRIAGE_STATUS.RESOLVED &&
|
||||
triage.rawFindingStatus === FINDING_STATUS.MANUAL &&
|
||||
triage.status !== FINDING_TRIAGE_STATUS.RESOLVED;
|
||||
const canSubmit =
|
||||
triage.canEdit && Boolean(onTriageUpdateAction) && !isSubmitting;
|
||||
canEdit && (!isManualPassSelected || manualPassEvidence.trim().length > 0);
|
||||
const isCloudOnly =
|
||||
triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY;
|
||||
const shouldShowMutelistInfo =
|
||||
canSubmit &&
|
||||
canEdit &&
|
||||
!triage.isMuted &&
|
||||
selectedStatus !== triage.status &&
|
||||
isMutelistShortcutStatus(selectedStatus);
|
||||
const shouldShowRemediatingInfo =
|
||||
selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING;
|
||||
const isStatusLocked = isTriageStatusLocked(triage.status);
|
||||
const shouldShowManualPassProvenance =
|
||||
triage.manualPassCreatedAt !== null && triage.manualPassExpiresAt !== null;
|
||||
const isPreviousManualPass = triage.manualPassActive === false;
|
||||
const manualPassState =
|
||||
triage.manualPassActive === true
|
||||
? "Active"
|
||||
: isPreviousManualPass
|
||||
? triage.manualPassDeactivatedAt
|
||||
? "Inactive"
|
||||
: "Expired"
|
||||
: null;
|
||||
const hasManualPassEvidence = Boolean(triage.manualPassEvidence?.trim());
|
||||
// Opened from a dropdown item: move focus into the dialog on mount so Radix's
|
||||
// aria-hidden is not applied to the still-focused dropdown that opened it.
|
||||
const handleOpenAutoFocus = (event: Event) => {
|
||||
@@ -98,6 +141,16 @@ export function FindingNoteModal({
|
||||
// Otherwise let Radix auto-focus the first control inside the dialog.
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setSelectedStatus(initialSelectedStatus);
|
||||
setNote(triage.noteBody);
|
||||
setManualPassEvidence("");
|
||||
setSubmitError(null);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -113,17 +166,36 @@ export function FindingNoteModal({
|
||||
triage,
|
||||
selectedStatus,
|
||||
noteBody: note,
|
||||
manualPassEvidence,
|
||||
});
|
||||
|
||||
if (!updateInput) {
|
||||
onOpenChange(false);
|
||||
handleOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await onTriageUpdateAction?.(updateInput);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
setSubmitError("Could not update the note. Please try again.");
|
||||
const updateResult = await onTriageUpdateAction?.(updateInput);
|
||||
|
||||
if (isManualPassSelected && updateResult?.manualPassExpiresAt) {
|
||||
const formattedExpiry = formatLocalDate(
|
||||
updateResult.manualPassExpiresAt,
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Finding manually verified as Pass",
|
||||
description: formattedExpiry
|
||||
? `Triage: Resolved · Valid until ${formattedExpiry}`
|
||||
: "Triage: Resolved",
|
||||
});
|
||||
}
|
||||
|
||||
handleOpenChange(false);
|
||||
} catch (error) {
|
||||
setSubmitError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not update the note. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -132,29 +204,41 @@ export function FindingNoteModal({
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
title="Add Triage Note"
|
||||
title={isManualPassDetails ? "Manual Pass Details" : "Add Triage Note"}
|
||||
description={
|
||||
isManualPassDetails
|
||||
? "Authoritative Manual Pass evidence and provenance."
|
||||
: undefined
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
{/* min-w-0: the form is a grid item of DialogContent; without it, long
|
||||
unbreakable content (e.g. resource UIDs) widens the grid track past
|
||||
the modal instead of truncating. */}
|
||||
<form className="flex min-w-0 flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="text-text-neutral-secondary flex flex-wrap items-center gap-2 text-sm">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<span>Learn how triage states work in the</span>
|
||||
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
|
||||
<a
|
||||
href={DOCS_URLS.FINDINGS_TRIAGE}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{!isManualPassDetails && (
|
||||
<div className="text-text-neutral-secondary flex flex-wrap items-center gap-2 text-sm">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<span>Learn how triage states work in the</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
className="h-auto p-0"
|
||||
asChild
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Triage documentation</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
href={DOCS_URLS.FINDINGS_TRIAGE}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Triage documentation</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-border-input-primary flex items-center gap-4 rounded-lg border p-3">
|
||||
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
@@ -192,8 +276,15 @@ export function FindingNoteModal({
|
||||
<div className="w-1/2 min-w-44">
|
||||
<FindingTriageStatusControl
|
||||
origin={FINDING_TRIAGE_ORIGIN.MODAL}
|
||||
triage={triage}
|
||||
triage={
|
||||
isSubmitting || isManualPassDetails
|
||||
? { ...triage, canEdit: false }
|
||||
: triage
|
||||
}
|
||||
value={selectedStatus}
|
||||
includeManualPass={
|
||||
triage.rawFindingStatus === FINDING_STATUS.MANUAL
|
||||
}
|
||||
onValueChange={setSelectedStatus}
|
||||
/>
|
||||
</div>
|
||||
@@ -207,6 +298,74 @@ export function FindingNoteModal({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shouldShowManualPassProvenance && (
|
||||
<Alert variant="info">
|
||||
<AlertDescription>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
{isPreviousManualPass
|
||||
? "Previous Manual Pass"
|
||||
: MANUAL_PASS_PROVENANCE}
|
||||
{triage.manualPassCreatedByName
|
||||
? ` by ${triage.manualPassCreatedByName}`
|
||||
: ""}
|
||||
.
|
||||
</span>
|
||||
{manualPassState && (
|
||||
<Badge
|
||||
variant={
|
||||
manualPassState === "Active" ? "success" : "warning"
|
||||
}
|
||||
>
|
||||
{manualPassState}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasManualPassEvidence && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Evidence</span>
|
||||
<p className="text-text-neutral-primary">
|
||||
{triage.manualPassEvidence}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Attested</span>
|
||||
<DateWithTime
|
||||
inline
|
||||
dateTime={triage.manualPassCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{triage.manualPassDeactivatedAt
|
||||
? "Was valid until"
|
||||
: isPreviousManualPass
|
||||
? "Expired on"
|
||||
: "Valid until"}
|
||||
</span>
|
||||
<DateWithTime
|
||||
inline
|
||||
dateTime={triage.manualPassExpiresAt}
|
||||
/>
|
||||
</div>
|
||||
{triage.manualPassDeactivatedAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Inactive on</span>
|
||||
<DateWithTime
|
||||
inline
|
||||
dateTime={triage.manualPassDeactivatedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shouldShowMutelistInfo && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
@@ -227,62 +386,111 @@ export function FindingNoteModal({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={noteTextareaRef}
|
||||
id="finding-triage-note"
|
||||
aria-label="Note text"
|
||||
value={note}
|
||||
maxLength={triage.maxNoteLength}
|
||||
disabled={!canSubmit}
|
||||
textareaSize="lg"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<p className="text-text-neutral-tertiary shrink-0 text-xs">
|
||||
{note.length}/{triage.maxNoteLength}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* mt-3 lifts the gap-5 form spacing to 32px so the distance to the
|
||||
footer matches the launch scan and alert modals. */}
|
||||
<div className="mt-3 flex w-full justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<span className="relative inline-flex">
|
||||
{isCloudOnly && (
|
||||
<span className="pointer-events-none absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
|
||||
<Badge variant="cloud">Cloud</Badge>
|
||||
</span>
|
||||
)}
|
||||
{isManualPassDetails ? (
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
type={canSubmit ? "submit" : "button"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
aria-label={
|
||||
isCloudOnly ? "Save - available in Prowler Cloud" : undefined
|
||||
}
|
||||
disabled={!canSubmit && !isCloudOnly}
|
||||
onClick={
|
||||
isCloudOnly
|
||||
? () => openCloudUpgrade(CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: canSubmit || isCloudOnly
|
||||
? "Save"
|
||||
: "Unavailable"}
|
||||
Close
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{isManualPassSelected ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="finding-manual-pass-evidence">
|
||||
Manual pass evidence
|
||||
</FieldLabel>
|
||||
<p
|
||||
id="finding-manual-pass-evidence-guidance"
|
||||
className="text-text-neutral-secondary text-sm"
|
||||
>
|
||||
{MANUAL_PASS_NOTE_REQUIRED_COPY}
|
||||
</p>
|
||||
<Textarea
|
||||
ref={noteTextareaRef}
|
||||
id="finding-manual-pass-evidence"
|
||||
aria-describedby="finding-manual-pass-evidence-guidance"
|
||||
required
|
||||
value={manualPassEvidence}
|
||||
maxLength={triage.maxNoteLength}
|
||||
disabled={!canEdit}
|
||||
textareaSize="lg"
|
||||
onChange={(event) =>
|
||||
setManualPassEvidence(event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Textarea
|
||||
ref={noteTextareaRef}
|
||||
id="finding-triage-note"
|
||||
aria-label="Note text"
|
||||
value={note}
|
||||
maxLength={triage.maxNoteLength}
|
||||
disabled={!canEdit}
|
||||
textareaSize="lg"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<p className="text-text-neutral-tertiary shrink-0 text-xs">
|
||||
{isManualPassSelected
|
||||
? manualPassEvidence.length
|
||||
: note.length}
|
||||
/{triage.maxNoteLength}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* mt-3 lifts the gap-5 form spacing to 32px so the distance to the
|
||||
footer matches the launch scan and alert modals. */}
|
||||
<div className="mt-3 flex w-full justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<span className="relative inline-flex">
|
||||
{isCloudOnly && (
|
||||
<span className="pointer-events-none absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
|
||||
<Badge variant="cloud">Cloud</Badge>
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type={canSubmit ? "submit" : "button"}
|
||||
size="lg"
|
||||
aria-label={
|
||||
isCloudOnly
|
||||
? "Save - available in Prowler Cloud"
|
||||
: undefined
|
||||
}
|
||||
disabled={!canSubmit && !isCloudOnly}
|
||||
onClick={
|
||||
isCloudOnly
|
||||
? () =>
|
||||
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: canEdit || isCloudOnly
|
||||
? "Save"
|
||||
: "Unavailable"}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,18 @@ import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings/findings-triage", () => ({
|
||||
loadFindingTriageDetail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
@@ -70,9 +82,11 @@ beforeAll(() => {
|
||||
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
@@ -100,7 +114,27 @@ function makeTriageSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function makeTriageDetail(
|
||||
overrides?: Partial<FindingTriageDetail>,
|
||||
): FindingTriageDetail {
|
||||
return {
|
||||
...makeTriageSummary({ rawFindingStatus: FINDING_STATUS.MANUAL }),
|
||||
noteId: null,
|
||||
noteBody: "",
|
||||
maxNoteLength: 500,
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
manualPassCreatedByName: null,
|
||||
manualPassCreatedAt: null,
|
||||
manualPassExpiresAt: null,
|
||||
manualPassActive: null,
|
||||
manualPassEvidence: null,
|
||||
manualPassDeactivatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
toastMock.mockReset();
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
});
|
||||
|
||||
@@ -251,6 +285,522 @@ describe("finding triage cells", () => {
|
||||
"False Positive",
|
||||
),
|
||||
).toHaveClass("text-text-neutral-secondary");
|
||||
expect(screen.queryByRole("option", { name: "Resolved" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should show Resolved as a normal enabled option only for an authoritative MANUAL finding", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// Then
|
||||
const resolvedOption = screen.getByRole("option", {
|
||||
name: "Resolved",
|
||||
});
|
||||
expect(resolvedOption).not.toHaveAttribute("aria-disabled");
|
||||
expect(resolvedOption).not.toHaveAttribute("disabled");
|
||||
expect(resolvedOption).not.toHaveAttribute("data-disabled");
|
||||
expect(screen.queryByRole("option", { name: "Pass" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([FINDING_STATUS.FAIL, FINDING_STATUS.PASS] as const)(
|
||||
"should not show the extra Resolved workflow for a %s raw finding",
|
||||
async (rawFindingStatus) => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({ rawFindingStatus })}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("option", { name: "Resolved" })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("should explain the gated manual pass action inline", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
// Then
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Resolved" })).getByText(
|
||||
"Add a Triage Note explaining why this finding passes.",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("should open Triage Note with Resolved preselected without a direct update", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const triage = makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const detail = makeTriageDetail();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
const onTriageDetailLoadAction = vi.fn().mockResolvedValue(detail);
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={triage}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
expect(onTriageDetailLoadAction).toHaveBeenCalledWith(triage);
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "Add Triage Note",
|
||||
});
|
||||
expect(dialog).toBeVisible();
|
||||
expect(
|
||||
within(dialog).getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Resolved");
|
||||
expect(within(dialog).queryByText("Pass")).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Manual")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Manual pass evidence")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should use fresh detail status instead of stale MANUAL summary preselection", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const staleTriage = makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const freshDetail = makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
rawFindingStatus: FINDING_STATUS.FAIL,
|
||||
});
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={staleTriage}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi.fn().mockResolvedValue(freshDetail)}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// Then
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "Add Triage Note",
|
||||
});
|
||||
expect(
|
||||
within(dialog).getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Under Review");
|
||||
expect(within(dialog).queryByLabelText("Manual pass evidence")).toBeNull();
|
||||
});
|
||||
|
||||
it("should optimistically show Resolved after manual pass submission", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={vi.fn().mockResolvedValue(makeTriageDetail())}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
await user.type(
|
||||
await screen.findByLabelText("Manual pass evidence"),
|
||||
"Verified by the control owner.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(onTriageUpdateAction).toHaveBeenCalledOnce());
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Resolved");
|
||||
expect(screen.getByText("Manually verified")).toBeVisible();
|
||||
});
|
||||
|
||||
it("should clear cancelled manual pass evidence before reopening", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi.fn().mockResolvedValue(makeTriageDetail())}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
await user.type(
|
||||
await screen.findByLabelText("Manual pass evidence"),
|
||||
"Evidence that must not leak.",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByLabelText("Manual pass evidence")).toHaveValue(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep Manual Pass provenance stable while preventing duplicate detail requests", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveDetail: (detail: FindingTriageDetail) => void = () => {};
|
||||
const onTriageDetailLoadAction = vi.fn(
|
||||
() =>
|
||||
new Promise<FindingTriageDetail>((resolve) => {
|
||||
resolveDetail = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>,
|
||||
);
|
||||
const provenanceControl = screen.getByRole("button", {
|
||||
name: "View Manual Pass details",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(provenanceControl);
|
||||
await user.click(provenanceControl);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Loading current triage details..."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(provenanceControl).toBeVisible();
|
||||
expect(provenanceControl).toBeDisabled();
|
||||
expect(onTriageDetailLoadAction).toHaveBeenCalledOnce();
|
||||
|
||||
resolveDetail(makeTriageDetail());
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Manual Pass Details" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("should open Triage Note from keyboard activation without a direct update", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const triage = makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
const onTriageDetailLoadAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue(makeTriageDetail());
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.keyboard("{End}");
|
||||
|
||||
// When
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
expect(onTriageDetailLoadAction).toHaveBeenCalledWith(triage);
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Manual pass evidence")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should show Resolved as current triage and manual verification as secondary provenance", () => {
|
||||
// Given / When
|
||||
const { rerender } = render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Resolved");
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).not.toHaveTextContent("Pass");
|
||||
expect(screen.getByText("Manually verified")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).not.toHaveTextContent("Manual");
|
||||
|
||||
// When
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
rawFindingStatus: FINDING_STATUS.PASS,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Resolved");
|
||||
expect(screen.queryByText("Manually verified")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should load authoritative Manual Pass evidence from the provenance action", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
const onRowClick = vi.fn();
|
||||
const triage = makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const detail = makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassActive: true,
|
||||
manualPassEvidence: "The control owner verified the production evidence.",
|
||||
manualPassCreatedByName: "Alex Security",
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
});
|
||||
const onTriageDetailLoadAction = vi.fn().mockResolvedValue(detail);
|
||||
render(
|
||||
<div onClick={onRowClick}>
|
||||
<FindingTriageStatusCell
|
||||
triage={triage}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "View Manual Pass details" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onTriageDetailLoadAction).toHaveBeenCalledWith(triage);
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "Manual Pass Details",
|
||||
});
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
"The control owner verified the production evidence.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(dialog).getByText(/Manually verified by Alex Security/i),
|
||||
).toBeVisible();
|
||||
expect(within(dialog).getByText("Active")).toBeVisible();
|
||||
expect(within(dialog).getByText("Jun 03, 2026")).toBeVisible();
|
||||
expect(within(dialog).getByText("Jun 17, 2026")).toBeVisible();
|
||||
const statusControl = within(dialog).getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
expect(statusControl).toHaveTextContent("Resolved");
|
||||
expect(statusControl).toBeDisabled();
|
||||
expect(
|
||||
within(dialog).queryByLabelText("Note text"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByLabelText("Manual pass evidence"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should preserve Open in inactive Manual Pass details", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const triage = makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
manualPassProvenance: "Manually verified",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
const onTriageDetailLoadAction = vi.fn().mockResolvedValue(
|
||||
makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
manualPassActive: false,
|
||||
manualPassEvidence: "Previously verified.",
|
||||
manualPassCreatedAt: "2026-06-03T10:00:00Z",
|
||||
manualPassExpiresAt: "2026-06-17T10:00:00Z",
|
||||
manualPassDeactivatedAt: "2026-06-10T10:00:00Z",
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={triage}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "View Manual Pass details" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
within(
|
||||
await screen.findByRole("dialog", { name: "Manual Pass Details" }),
|
||||
).getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Open");
|
||||
});
|
||||
|
||||
it("should keep Manual Pass provenance inert without a detail loader", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Manually verified")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "View Manual Pass details" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should report a failed Manual Pass provenance detail load", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("load failed"))}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "View Manual Pass details" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load current triage details.",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Manual Pass Details" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle missing Manual Pass detail without opening an empty modal", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "View Manual Pass details" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load current triage details.",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Manual Pass Details" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a read-only triage status badge with the status color", () => {
|
||||
@@ -459,7 +1009,7 @@ describe("finding triage cells", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expose a screen-reader error when an existing note cannot load", async () => {
|
||||
it("should show a visible error when an existing note cannot load", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageNoteLoadAction = vi
|
||||
@@ -478,14 +1028,74 @@ describe("finding triage cells", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Open note" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load the existing note.",
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Could not load the existing note.",
|
||||
description: "Please try again.",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a visible error when current triage details cannot load for a new note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageDetailLoadAction = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("load failed"));
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary()}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Could not load current triage details.",
|
||||
description: "Please try again.",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should visibly announce when current triage details cannot load", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageDetailLoadAction={vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("load failed"))}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("option", { name: "Resolved" }));
|
||||
|
||||
// Then
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Could not load current triage details.");
|
||||
expect(alert).toBeVisible();
|
||||
});
|
||||
|
||||
it("should keep the optimistic table status while stale props are rendered during update", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -654,7 +1264,7 @@ describe("finding triage cells", () => {
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should rollback table status and expose an error when update fails", async () => {
|
||||
it("should rollback table status and show an error when update fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
@@ -677,8 +1287,12 @@ describe("finding triage cells", () => {
|
||||
await user.click(screen.getByRole("option", { name: "Remediating" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not update triage status.",
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Could not update triage status.",
|
||||
description: "Please try again.",
|
||||
}),
|
||||
);
|
||||
expect(statusControl).toHaveTextContent("Open");
|
||||
});
|
||||
|
||||
@@ -5,35 +5,42 @@ import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { ActionDropdownItem } from "@/components/shadcn/dropdown";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
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";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageContext,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageDetailLoadHandler,
|
||||
type FindingTriageLoadedNote,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageNoteLoadHandler,
|
||||
type FindingTriageSummary,
|
||||
type FindingTriageUpdateHandler,
|
||||
isManualStatus,
|
||||
isTriageStatusLocked,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FINDING_NOTE_MODAL_MODE,
|
||||
FindingNoteModal,
|
||||
type FindingTriageContext,
|
||||
type FindingNoteModalMode,
|
||||
} from "./finding-note-modal";
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
type FindingTriageUpdateHandler,
|
||||
TRIAGE_STATUS_TEXT_CLASS,
|
||||
} from "./finding-triage-status-control";
|
||||
|
||||
@@ -73,43 +80,84 @@ const getTriageDetailFromSummary = (
|
||||
noteId: loadedNote?.noteId ?? null,
|
||||
noteBody: loadedNote?.noteBody ?? "",
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
rawFindingStatus: triage.rawFindingStatus ?? null,
|
||||
manualPassCreatedByName: null,
|
||||
manualPassCreatedAt: null,
|
||||
manualPassExpiresAt: null,
|
||||
manualPassActive: null,
|
||||
manualPassEvidence: null,
|
||||
manualPassDeactivatedAt: null,
|
||||
});
|
||||
|
||||
const getManualPassModalInitialStatus = (
|
||||
mode: FindingNoteModalMode,
|
||||
detail: FindingTriageDetail,
|
||||
) => {
|
||||
if (
|
||||
mode === FINDING_NOTE_MODAL_MODE.MANUAL_PASS_DETAILS &&
|
||||
detail.status === FINDING_TRIAGE_STATUS.RESOLVED
|
||||
) {
|
||||
return FINDING_TRIAGE_STATUS.RESOLVED;
|
||||
}
|
||||
|
||||
if (
|
||||
mode === FINDING_NOTE_MODAL_MODE.EDIT &&
|
||||
detail.rawFindingStatus === FINDING_STATUS.MANUAL
|
||||
) {
|
||||
return FINDING_TRIAGE_STATUS.RESOLVED;
|
||||
}
|
||||
|
||||
return isManualStatus(detail.status)
|
||||
? detail.status
|
||||
: FINDING_TRIAGE_STATUS.OPEN;
|
||||
};
|
||||
|
||||
export function FindingTriageStatusCell({
|
||||
triage,
|
||||
findingContext = { title: "Finding" },
|
||||
onTriageUpdateAction,
|
||||
onTriageDetailLoadAction,
|
||||
}: {
|
||||
triage?: FindingTriageSummary;
|
||||
findingContext?: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}) {
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
const [optimisticStatus, setOptimisticStatus] = useState<{
|
||||
token: string;
|
||||
findingId: string;
|
||||
triageId: string | null;
|
||||
previousStatus: FindingTriageStatus;
|
||||
status: FindingTriageStatus;
|
||||
input: UpdateFindingTriageInput;
|
||||
} | null>(null);
|
||||
const [manualPassDetail, setManualPassDetail] =
|
||||
useState<FindingTriageDetail>();
|
||||
const [manualPassModalMode, setManualPassModalMode] =
|
||||
useState<FindingNoteModalMode>(FINDING_NOTE_MODAL_MODE.EDIT);
|
||||
const [isManualPassModalOpen, setIsManualPassModalOpen] = useState(false);
|
||||
const [isManualPassLoading, setIsManualPassLoading] = useState(false);
|
||||
const [manualPassLoadError, setManualPassLoadError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Retire the optimistic status once the server converges or the row changes, so a stale value can't resurface.
|
||||
if (
|
||||
optimisticStatus &&
|
||||
(!triage ||
|
||||
optimisticStatus.findingId !== triage.findingId ||
|
||||
optimisticStatus.triageId !== triage.triageId ||
|
||||
triage.status === optimisticStatus.status)
|
||||
optimisticStatus.input.findingId !== triage.findingId ||
|
||||
optimisticStatus.input.triageId !== triage.triageId ||
|
||||
triage.status === optimisticStatus.input.status)
|
||||
) {
|
||||
setOptimisticStatus(null);
|
||||
}
|
||||
|
||||
const optimisticMatchesCurrentTriage =
|
||||
Boolean(triage) &&
|
||||
optimisticStatus?.findingId === triage?.findingId &&
|
||||
optimisticStatus?.triageId === triage?.triageId &&
|
||||
optimisticStatus?.previousStatus === triage?.status &&
|
||||
optimisticStatus?.status !== triage?.status;
|
||||
optimisticStatus?.input.findingId === triage?.findingId &&
|
||||
optimisticStatus?.input.triageId === triage?.triageId &&
|
||||
(optimisticStatus?.input.previousStatus ?? triage?.status) ===
|
||||
triage?.status &&
|
||||
optimisticStatus?.input.status !== triage?.status;
|
||||
|
||||
if (!triage) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
@@ -117,12 +165,11 @@ export function FindingTriageStatusCell({
|
||||
|
||||
const displayedTriage =
|
||||
optimisticMatchesCurrentTriage && optimisticStatus
|
||||
? {
|
||||
...triage,
|
||||
status: optimisticStatus.status,
|
||||
label: FINDING_TRIAGE_STATUS_LABELS[optimisticStatus.status],
|
||||
}
|
||||
? applyOptimisticTriageSummaryUpdate(triage, optimisticStatus.input)
|
||||
: triage;
|
||||
const interactiveTriage = isManualPassLoading
|
||||
? { ...displayedTriage, canEdit: false }
|
||||
: displayedTriage;
|
||||
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
const optimisticToken = input.status ? crypto.randomUUID() : null;
|
||||
@@ -130,15 +177,12 @@ export function FindingTriageStatusCell({
|
||||
if (input.status && optimisticToken) {
|
||||
setOptimisticStatus({
|
||||
token: optimisticToken,
|
||||
findingId: input.findingId,
|
||||
triageId: input.triageId,
|
||||
previousStatus: input.previousStatus ?? triage.status,
|
||||
status: input.status,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await onTriageUpdateAction?.(input);
|
||||
return await onTriageUpdateAction?.(input);
|
||||
} catch (error) {
|
||||
setOptimisticStatus((current) =>
|
||||
current?.token === optimisticToken ? null : current,
|
||||
@@ -147,19 +191,76 @@ export function FindingTriageStatusCell({
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualPassRequest = async (
|
||||
mode: FindingNoteModalMode = FINDING_NOTE_MODAL_MODE.EDIT,
|
||||
) => {
|
||||
if (!onTriageDetailLoadAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setManualPassLoadError(null);
|
||||
setIsManualPassLoading(true);
|
||||
|
||||
try {
|
||||
const detail = await onTriageDetailLoadAction(triage);
|
||||
|
||||
if (!detail) {
|
||||
throw new Error("Missing triage detail");
|
||||
}
|
||||
|
||||
setManualPassDetail(detail);
|
||||
setManualPassModalMode(mode);
|
||||
setIsManualPassModalOpen(true);
|
||||
} catch {
|
||||
setManualPassLoadError("Could not load current triage details.");
|
||||
} finally {
|
||||
setIsManualPassLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const control = (
|
||||
<div
|
||||
className="flex w-32 flex-col items-start gap-1"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<FindingTriageStatusControl
|
||||
key={displayedTriage.findingId}
|
||||
origin={FINDING_TRIAGE_ORIGIN.TABLE}
|
||||
triage={displayedTriage}
|
||||
triage={interactiveTriage}
|
||||
onTriageUpdateAction={
|
||||
onTriageUpdateAction ? handleTriageUpdate : undefined
|
||||
}
|
||||
onManualPassRequest={
|
||||
onTriageDetailLoadAction
|
||||
? () => void handleManualPassRequest()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{displayedTriage.manualPassProvenance && (
|
||||
<>
|
||||
{onTriageDetailLoadAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="link-xs"
|
||||
aria-label="View Manual Pass details"
|
||||
disabled={isManualPassLoading}
|
||||
onClick={() =>
|
||||
void handleManualPassRequest(
|
||||
FINDING_NOTE_MODAL_MODE.MANUAL_PASS_DETAILS,
|
||||
)
|
||||
}
|
||||
>
|
||||
{displayedTriage.manualPassProvenance}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{displayedTriage.manualPassProvenance}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -168,8 +269,39 @@ export function FindingTriageStatusCell({
|
||||
hasUpdateHandler: Boolean(onTriageUpdateAction),
|
||||
lockResolved: true,
|
||||
});
|
||||
const manualPassModal =
|
||||
manualPassDetail && isManualPassModalOpen ? (
|
||||
<FindingNoteModal
|
||||
open={isManualPassModalOpen}
|
||||
onOpenChange={setIsManualPassModalOpen}
|
||||
triage={manualPassDetail}
|
||||
findingContext={findingContext}
|
||||
mode={manualPassModalMode}
|
||||
initialStatus={getManualPassModalInitialStatus(
|
||||
manualPassModalMode,
|
||||
manualPassDetail,
|
||||
)}
|
||||
onTriageUpdateAction={
|
||||
manualPassModalMode === FINDING_NOTE_MODAL_MODE.EDIT
|
||||
? handleTriageUpdate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
const statusContent = (
|
||||
<>
|
||||
{control}
|
||||
{manualPassLoadError && (
|
||||
<span className="text-text-error-primary text-xs" role="alert">
|
||||
{manualPassLoadError}
|
||||
</span>
|
||||
)}
|
||||
{manualPassModal}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!disabledCopy) {
|
||||
return control;
|
||||
return statusContent;
|
||||
}
|
||||
|
||||
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) {
|
||||
@@ -177,7 +309,7 @@ export function FindingTriageStatusCell({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="relative flex">
|
||||
{control}
|
||||
{statusContent}
|
||||
<Button
|
||||
type="button"
|
||||
variant="bare"
|
||||
@@ -201,7 +333,7 @@ export function FindingTriageStatusCell({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Block-level wrapper keeps the picker aligned with the sibling columns. */}
|
||||
<span className="flex">{control}</span>
|
||||
<span className="flex">{statusContent}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{disabledCopy}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -235,13 +367,13 @@ export function FindingNoteActionItem({
|
||||
findingContext = { title: "Finding" },
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
onTriageDetailLoadAction,
|
||||
}: {
|
||||
triage?: FindingTriageSummary;
|
||||
findingContext?: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
onTriageNoteLoadAction?: FindingTriageNoteLoadHandler;
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}) {
|
||||
if (!triage) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
@@ -256,6 +388,7 @@ export function FindingNoteActionItem({
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -265,18 +398,19 @@ function FindingNoteActionItemContent({
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
onTriageDetailLoadAction,
|
||||
}: {
|
||||
triage: FindingTriageSummary;
|
||||
findingContext: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
onTriageNoteLoadAction?: FindingTriageNoteLoadHandler;
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [loadedNote, setLoadedNote] = useState<FindingTriageLoadedNote>();
|
||||
const [loadedDetail, setLoadedDetail] = useState<FindingTriageDetail>();
|
||||
const [isLoadingNote, setIsLoadingNote] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const hasUpdateHandler = Boolean(onTriageUpdateAction);
|
||||
const isCloudOnly =
|
||||
@@ -305,24 +439,35 @@ function FindingNoteActionItemContent({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!triage.hasVisibleNote) {
|
||||
if (isCloudOnly || (!onTriageDetailLoadAction && !triage.hasVisibleNote)) {
|
||||
setIsNoteModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onTriageNoteLoadAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadError(null);
|
||||
setIsLoadingNote(true);
|
||||
|
||||
try {
|
||||
const note = await onTriageNoteLoadAction(triage);
|
||||
setLoadedNote(note);
|
||||
const [detail, note] = await Promise.all([
|
||||
onTriageDetailLoadAction
|
||||
? onTriageDetailLoadAction(triage)
|
||||
: Promise.resolve(getTriageDetailFromSummary(triage)),
|
||||
triage.hasVisibleNote && onTriageNoteLoadAction
|
||||
? onTriageNoteLoadAction(triage)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
setLoadedDetail(detail);
|
||||
if (note) {
|
||||
setLoadedNote(note);
|
||||
}
|
||||
setIsNoteModalOpen(true);
|
||||
} catch {
|
||||
setLoadError("Could not load the existing note.");
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: triage.hasVisibleNote
|
||||
? "Could not load the existing note."
|
||||
: "Could not load current triage details.",
|
||||
description: "Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingNote(false);
|
||||
}
|
||||
@@ -332,7 +477,15 @@ function FindingNoteActionItemContent({
|
||||
<FindingNoteModal
|
||||
open={isNoteModalOpen}
|
||||
onOpenChange={setIsNoteModalOpen}
|
||||
triage={getTriageDetailFromSummary(triage, loadedNote)}
|
||||
triage={
|
||||
loadedDetail
|
||||
? {
|
||||
...loadedDetail,
|
||||
noteId: loadedNote?.noteId ?? loadedDetail.noteId,
|
||||
noteBody: loadedNote?.noteBody ?? loadedDetail.noteBody,
|
||||
}
|
||||
: getTriageDetailFromSummary(triage, loadedNote)
|
||||
}
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
@@ -354,11 +507,6 @@ function FindingNoteActionItemContent({
|
||||
void handleNoteSelect();
|
||||
}}
|
||||
/>
|
||||
{loadError && (
|
||||
<span className="sr-only" role="alert">
|
||||
{loadError}
|
||||
</span>
|
||||
)}
|
||||
{noteModal}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,25 +10,25 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
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,
|
||||
isManualStatus,
|
||||
isMutelistShortcutStatus,
|
||||
isTriageStatusLocked,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export type FindingTriageUpdateHandler = (
|
||||
input: UpdateFindingTriageInput,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type TriageStatusPickerSize = NonNullable<
|
||||
ComponentProps<typeof SelectTrigger>["size"]
|
||||
>;
|
||||
@@ -44,25 +44,31 @@ export const TRIAGE_STATUS_TEXT_CLASS = {
|
||||
} 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;
|
||||
onValueChange: (status: FindingTriageManualStatus) => void;
|
||||
statusValues: readonly FindingTriageModalStatus[];
|
||||
showManualPassTooltip?: boolean;
|
||||
onValueChange: (status: FindingTriageModalStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextStatus) => {
|
||||
if (isManualStatus(nextStatus as FindingTriageStatus)) {
|
||||
onValueChange(nextStatus as FindingTriageManualStatus);
|
||||
if (statusValues.includes(nextStatus as FindingTriageModalStatus)) {
|
||||
onValueChange(nextStatus as FindingTriageModalStatus);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -77,13 +83,31 @@ function TriageStatusPicker({
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FINDING_TRIAGE_MANUAL_STATUS_VALUES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[status])}>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
@@ -93,13 +117,15 @@ type TableStatusControlProps = {
|
||||
origin: typeof FINDING_TRIAGE_ORIGIN.TABLE;
|
||||
triage: FindingTriageSummary;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onManualPassRequest?: () => void;
|
||||
};
|
||||
|
||||
type ModalStatusControlProps = {
|
||||
origin: typeof FINDING_TRIAGE_ORIGIN.MODAL;
|
||||
triage: FindingTriageSummary;
|
||||
value: FindingTriageStatus;
|
||||
onValueChange: (status: FindingTriageManualStatus) => void;
|
||||
value: FindingTriageModalStatus;
|
||||
includeManualPass: boolean;
|
||||
onValueChange: (status: FindingTriageModalStatus) => void;
|
||||
};
|
||||
|
||||
type FindingTriageStatusControlProps =
|
||||
@@ -109,7 +135,7 @@ type FindingTriageStatusControlProps =
|
||||
export function FindingTriageStatusControl(
|
||||
props: FindingTriageStatusControlProps,
|
||||
) {
|
||||
const [tableUpdateError, setTableUpdateError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const [isTableUpdating, setIsTableUpdating] = useState(false);
|
||||
const [pendingShortcutStatus, setPendingShortcutStatus] =
|
||||
useState<FindingTriageManualStatus | null>(null);
|
||||
@@ -120,6 +146,11 @@ export function FindingTriageStatusControl(
|
||||
<TriageStatusPicker
|
||||
disabled={!triage.canEdit || isTriageStatusLocked(triage.status)}
|
||||
value={props.value}
|
||||
statusValues={
|
||||
props.includeManualPass
|
||||
? FINDING_TRIAGE_MODAL_STATUS_VALUES
|
||||
: FINDING_TRIAGE_MANUAL_STATUS_VALUES
|
||||
}
|
||||
onValueChange={props.onValueChange}
|
||||
/>
|
||||
);
|
||||
@@ -130,13 +161,16 @@ export function FindingTriageStatusControl(
|
||||
Boolean(props.onTriageUpdateAction) &&
|
||||
!isTableUpdating &&
|
||||
!isTriageStatusLocked(triage.status);
|
||||
const isAuthoritativeManual =
|
||||
triage.rawFindingStatus === FINDING_STATUS.MANUAL;
|
||||
const includeManualPass =
|
||||
isAuthoritativeManual && Boolean(props.onManualPassRequest);
|
||||
|
||||
const applyTableStatus = async (status: FindingTriageManualStatus) => {
|
||||
if (!props.onTriageUpdateAction || status === triage.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTableUpdateError(null);
|
||||
setIsTableUpdating(true);
|
||||
|
||||
try {
|
||||
@@ -150,7 +184,11 @@ export function FindingTriageStatusControl(
|
||||
isMuted: triage.isMuted,
|
||||
});
|
||||
} catch {
|
||||
setTableUpdateError("Could not update triage status.");
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not update triage status.",
|
||||
description: "Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsTableUpdating(false);
|
||||
}
|
||||
@@ -161,11 +199,16 @@ export function FindingTriageStatusControl(
|
||||
isMutelistShortcutStatus(status) &&
|
||||
!isMutelistShortcutStatus(triage.status);
|
||||
|
||||
const handleTableValueChange = (status: FindingTriageManualStatus) => {
|
||||
const handleTableValueChange = (status: FindingTriageModalStatus) => {
|
||||
if (!props.onTriageUpdateAction || status === triage.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === FINDING_TRIAGE_STATUS.RESOLVED) {
|
||||
props.onManualPassRequest?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldConfirmMute(status)) {
|
||||
setPendingShortcutStatus(status);
|
||||
return;
|
||||
@@ -181,14 +224,15 @@ export function FindingTriageStatusControl(
|
||||
disabled={!canMutateFromTable}
|
||||
size="xs"
|
||||
value={triage.status}
|
||||
statusValues={
|
||||
includeManualPass
|
||||
? FINDING_TRIAGE_MODAL_STATUS_VALUES
|
||||
: FINDING_TRIAGE_MANUAL_STATUS_VALUES
|
||||
}
|
||||
showManualPassTooltip={includeManualPass}
|
||||
onValueChange={handleTableValueChange}
|
||||
/>
|
||||
</div>
|
||||
{tableUpdateError && (
|
||||
<span className="sr-only" role="alert">
|
||||
{tableUpdateError}
|
||||
</span>
|
||||
)}
|
||||
<Modal
|
||||
open={pendingShortcutStatus !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
@@ -25,6 +26,13 @@ function makeTriageDetail(
|
||||
noteId: "note-1",
|
||||
noteBody: "Existing investigation note",
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
rawFindingStatus: FINDING_STATUS.FAIL,
|
||||
manualPassCreatedByName: null,
|
||||
manualPassCreatedAt: null,
|
||||
manualPassExpiresAt: null,
|
||||
manualPassActive: null,
|
||||
manualPassEvidence: null,
|
||||
manualPassDeactivatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -145,4 +153,68 @@ describe("buildFindingTriageUpdateInput", () => {
|
||||
note: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should build a manual pass from fresh evidence without reusing the editable note", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
noteBody: "Edited conversation note",
|
||||
manualPassEvidence: " Fresh evidence from the control owner. ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "Fresh evidence from the control owner.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject a manual pass without fresh nonblank evidence", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
noteBody: "Existing investigation note",
|
||||
manualPassEvidence: " ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject a manual pass when the raw status is not MANUAL", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail({
|
||||
rawFindingStatus: FINDING_STATUS.FAIL,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
noteBody: "Existing investigation note",
|
||||
manualPassEvidence: "The control owner verified this requirement.",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageManualStatus,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageModalStatus,
|
||||
isManualStatus,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export interface BuildFindingTriageUpdateInputParams {
|
||||
triage: FindingTriageDetail;
|
||||
selectedStatus: FindingTriageStatus;
|
||||
selectedStatus: FindingTriageModalStatus;
|
||||
noteBody: string;
|
||||
manualPassEvidence?: string;
|
||||
}
|
||||
|
||||
export function buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus,
|
||||
noteBody,
|
||||
manualPassEvidence = "",
|
||||
}: BuildFindingTriageUpdateInputParams): UpdateFindingTriageInput | null {
|
||||
const trimmedNote = noteBody.trim();
|
||||
const trimmedManualPassEvidence = manualPassEvidence.trim();
|
||||
const statusChanged = selectedStatus !== triage.status;
|
||||
|
||||
if (
|
||||
selectedStatus === FINDING_TRIAGE_STATUS.RESOLVED &&
|
||||
selectedStatus !== triage.status
|
||||
) {
|
||||
if (
|
||||
triage.rawFindingStatus !== FINDING_STATUS.MANUAL ||
|
||||
!isManualStatus(triage.status) ||
|
||||
trimmedManualPassEvidence.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
noteId: triage.noteId,
|
||||
isMuted: triage.isMuted,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: triage.status,
|
||||
manualPassEvidence: trimmedManualPassEvidence,
|
||||
};
|
||||
}
|
||||
const shouldCreateFirstNote =
|
||||
triage.notesCount === 0 && trimmedNote.length > 0;
|
||||
const shouldUpdateExistingNote =
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ChevronLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
loadFindingTriageDetail,
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
@@ -97,6 +98,7 @@ export function FindingsGroupDrillDown({
|
||||
onTriageUpdateAction: (input) =>
|
||||
updateTriageOptimistically(input, updateFindingTriage),
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
onTriageDetailLoadAction: loadFindingTriageDetail,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ChevronsDown } from "lucide-react";
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
loadFindingTriageDetail,
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
@@ -272,6 +273,7 @@ export function InlineResourceContainer({
|
||||
onTriageUpdateAction: (input) =>
|
||||
updateTriageOptimistically(input, updateFindingTriage),
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
onTriageDetailLoadAction: loadFindingTriageDetail,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
+3
@@ -24,6 +24,7 @@ const {
|
||||
mockNotificationIndicator,
|
||||
mockUpdateFindingTriage,
|
||||
mockLoadLatestFindingTriageNote,
|
||||
mockLoadFindingTriageDetail,
|
||||
mockRequestPanelChatMessage,
|
||||
mockRequestPanelSkillLaunch,
|
||||
mockIsCloud,
|
||||
@@ -38,6 +39,7 @@ const {
|
||||
mockNotificationIndicator: vi.fn(),
|
||||
mockUpdateFindingTriage: vi.fn(),
|
||||
mockLoadLatestFindingTriageNote: vi.fn(),
|
||||
mockLoadFindingTriageDetail: vi.fn(),
|
||||
mockRequestPanelChatMessage: vi.fn(),
|
||||
mockRequestPanelSkillLaunch: vi.fn(),
|
||||
mockIsCloud: vi.fn(() => true),
|
||||
@@ -300,6 +302,7 @@ vi.mock("@/actions/compliances", () => ({
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
updateFindingTriage: mockUpdateFindingTriage,
|
||||
loadLatestFindingTriageNote: mockLoadLatestFindingTriageNote,
|
||||
loadFindingTriageDetail: mockLoadFindingTriageDetail,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons", () => ({
|
||||
|
||||
+20
-4
@@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
loadFindingTriageDetail,
|
||||
loadLatestFindingTriageNote,
|
||||
type ResourceDrawerFinding,
|
||||
updateFindingTriage,
|
||||
@@ -83,7 +84,10 @@ import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import type {
|
||||
FindingTriageUpdateResult,
|
||||
UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
SKILL_LAUNCHER_VARIANT,
|
||||
@@ -447,13 +451,14 @@ export function ResourceDetailDrawerContent({
|
||||
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
|
||||
|
||||
const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
const result = await updateFindingTriage(input);
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
onMuteComplete();
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
onTriageUpdate?.(input);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Navigation only: the panel picks up the focused finding as context on its
|
||||
@@ -760,6 +765,7 @@ export function ResourceDetailDrawerContent({
|
||||
}}
|
||||
onTriageUpdateAction={handleDrawerTriageUpdate}
|
||||
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
||||
onTriageDetailLoadAction={loadFindingTriageDetail}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
@@ -1514,7 +1520,9 @@ function OtherFindingRow({
|
||||
finding: ResourceDrawerFinding;
|
||||
isOptimisticallyMuted: boolean;
|
||||
onMuted: () => void;
|
||||
onTriageUpdateAction: (input: UpdateFindingTriageInput) => Promise<void>;
|
||||
onTriageUpdateAction: (
|
||||
input: UpdateFindingTriageInput,
|
||||
) => Promise<FindingTriageUpdateResult | void>;
|
||||
}) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const isMuted = finding.isMuted || isOptimisticallyMuted;
|
||||
@@ -1576,7 +1584,14 @@ function OtherFindingRow({
|
||||
<TableCell>
|
||||
<FindingTriageStatusCell
|
||||
triage={finding.triage}
|
||||
findingContext={{
|
||||
title: finding.checkTitle,
|
||||
resource: finding.resourceName,
|
||||
provider: finding.providerAlias,
|
||||
providerType: finding.providerType,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={loadFindingTriageDetail}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
|
||||
@@ -1593,6 +1608,7 @@ function OtherFindingRow({
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
||||
onTriageDetailLoadAction={loadFindingTriageDetail}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
|
||||
+6
-6
@@ -910,22 +910,22 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
findingUid: "uid-2",
|
||||
triageId: "triage-2",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: false,
|
||||
note: "Investigating",
|
||||
manualPassEvidence: "Verified by the control owner.",
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.otherFindings[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
hasVisibleNote: true,
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
}),
|
||||
);
|
||||
expect(result.current.otherFindings[0]?.status).toBe("PASS");
|
||||
expect(result.current.currentFinding?.triage?.status).toBe(
|
||||
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
isManualPassTriageUpdate,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type FindingComplianceFramework,
|
||||
WATCHLIST_SCOPE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
// Keep fast carousel navigations in a loading state for one short beat so
|
||||
@@ -400,9 +402,11 @@ export function useResourceDetailDrawer({
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
const isManualPass = isManualPassTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
status: isManualPass ? FINDING_STATUS.PASS : finding.status,
|
||||
isMuted: shouldMarkMuted ? true : finding.isMuted,
|
||||
mutedReason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
|
||||
import { FindingProps } from "@/types";
|
||||
|
||||
export const ColumnLatestFindings: ColumnDef<FindingProps>[] =
|
||||
getStandaloneFindingColumns({
|
||||
includeUpdatedAt: true,
|
||||
onTriageUpdateAction: async (input) => {
|
||||
await updateFindingTriage(input);
|
||||
},
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./column-latest-findings";
|
||||
export * from "./latest-findings-table";
|
||||
export * from "./skeleton-table-new-findings";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getStandaloneFindingColumnsMock,
|
||||
refreshMock,
|
||||
updateFindingTriageMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getStandaloneFindingColumnsMock: vi.fn(),
|
||||
refreshMock: vi.fn(),
|
||||
updateFindingTriageMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: refreshMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
loadFindingTriageDetail: vi.fn(),
|
||||
loadLatestFindingTriageNote: vi.fn(),
|
||||
updateFindingTriage: updateFindingTriageMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/table/column-standalone-findings", () => ({
|
||||
getStandaloneFindingColumns: getStandaloneFindingColumnsMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/table", () => ({
|
||||
DataTable: ({ header }: { header?: ReactNode }) => <div>{header}</div>,
|
||||
}));
|
||||
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
|
||||
import { LatestFindingsTable } from "./latest-findings-table";
|
||||
|
||||
describe("LatestFindingsTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getStandaloneFindingColumnsMock.mockReturnValue([]);
|
||||
updateFindingTriageMock.mockResolvedValue({ manualPassExpiresAt: null });
|
||||
});
|
||||
|
||||
it("should refresh the full table after a manual pass update", async () => {
|
||||
// Given
|
||||
render(
|
||||
<LatestFindingsTable data={[]} header={<span>Latest findings</span>} />,
|
||||
);
|
||||
const onTriageUpdateAction = getStandaloneFindingColumnsMock.mock
|
||||
.calls[0][0].onTriageUpdateAction as (input: unknown) => Promise<unknown>;
|
||||
|
||||
// When
|
||||
await act(() =>
|
||||
onTriageUpdateAction({
|
||||
findingId: "finding-1",
|
||||
findingUid: "finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "Verified by the control owner.",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(refreshMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
loadFindingTriageDetail,
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
|
||||
import { DataTable } from "@/components/shadcn/table";
|
||||
import type { FindingProps } from "@/types";
|
||||
import type { FindingTriageUpdateHandler } from "@/types/findings-triage";
|
||||
|
||||
interface LatestFindingsTableProps {
|
||||
data: FindingProps[];
|
||||
header: ReactNode;
|
||||
}
|
||||
|
||||
export function LatestFindingsTable({
|
||||
data,
|
||||
header,
|
||||
}: LatestFindingsTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleTriageUpdate: FindingTriageUpdateHandler = async (input) => {
|
||||
const result = await updateFindingTriage(input);
|
||||
router.refresh();
|
||||
return result;
|
||||
};
|
||||
|
||||
const columns = getStandaloneFindingColumns({
|
||||
includeUpdatedAt: true,
|
||||
onTriageUpdateAction: handleTriageUpdate,
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
onTriageDetailLoadAction: loadFindingTriageDetail,
|
||||
});
|
||||
|
||||
return <DataTable columns={columns} data={data} header={header} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Container, CornerDownRight, Link } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
loadFindingTriageDetail,
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
@@ -37,11 +38,9 @@ import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { ProviderType, ResourceProps } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import type { ResourceFinding } from "@/types/resources";
|
||||
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
ResourceFinding,
|
||||
} from "./resource-findings-columns";
|
||||
import { getResourceFindingsColumns } from "./resource-findings-columns";
|
||||
import { useFindingDetails } from "./use-finding-details";
|
||||
import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
|
||||
|
||||
@@ -149,14 +148,15 @@ export const ResourceDetailContent = ({
|
||||
};
|
||||
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
const result = await updateFindingTriage(input);
|
||||
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
setFindingsReloadNonce((value) => value + 1);
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
patchTriageUpdate(input);
|
||||
return result;
|
||||
};
|
||||
|
||||
const failedFindings = findingsData;
|
||||
@@ -182,6 +182,7 @@ export const ResourceDetailContent = ({
|
||||
handleMuteComplete,
|
||||
handleTriageUpdate,
|
||||
loadLatestFindingTriageNote,
|
||||
loadFindingTriageDetail,
|
||||
);
|
||||
|
||||
const findingTitle =
|
||||
|
||||
@@ -48,11 +48,9 @@ import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import type { ResourceFinding } from "@/types/resources";
|
||||
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
type ResourceFinding,
|
||||
} from "./resource-findings-columns";
|
||||
import { getResourceFindingsColumns } from "./resource-findings-columns";
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
|
||||
@@ -6,40 +6,20 @@ import {
|
||||
DataTableRowActions,
|
||||
FindingTriageStatusCell,
|
||||
} from "@/components/findings/table";
|
||||
import type { FindingTriageUpdateHandler } from "@/components/findings/table/finding-triage-status-control";
|
||||
import {
|
||||
DeltaType,
|
||||
NotificationIndicator,
|
||||
} from "@/components/findings/table/notification-indicator";
|
||||
import { NotificationIndicator } from "@/components/findings/table/notification-indicator";
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import { DateWithTime } from "@/components/shadcn/entities";
|
||||
import {
|
||||
DataTableColumnHeader,
|
||||
Severity,
|
||||
SeverityBadge,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
FindingTriageDetailLoadHandler,
|
||||
FindingTriageNoteLoadHandler,
|
||||
FindingTriageUpdateHandler,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export interface ResourceFinding {
|
||||
type: "findings";
|
||||
id: string;
|
||||
triage?: FindingTriageSummary;
|
||||
attributes: {
|
||||
status: "PASS" | "FAIL" | "MANUAL";
|
||||
severity: Severity;
|
||||
muted?: boolean;
|
||||
muted_reason?: string;
|
||||
delta?: DeltaType;
|
||||
updated_at?: string;
|
||||
check_metadata?: {
|
||||
checktitle?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
import type { ResourceFinding } from "@/types/resources";
|
||||
|
||||
export const getResourceFindingsColumns = (
|
||||
rowSelection: RowSelectionState,
|
||||
@@ -47,9 +27,8 @@ export const getResourceFindingsColumns = (
|
||||
onNavigate: (id: string) => void,
|
||||
onMuteComplete?: (findingIds: string[]) => void,
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler,
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>,
|
||||
onTriageNoteLoadAction?: FindingTriageNoteLoadHandler,
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler,
|
||||
): ColumnDef<ResourceFinding>[] => {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
@@ -157,7 +136,12 @@ export const getResourceFindingsColumns = (
|
||||
cell: ({ row }) => (
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
findingContext={{
|
||||
title:
|
||||
row.original.attributes.check_metadata?.checktitle || "Finding",
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -171,6 +155,7 @@ export const getResourceFindingsColumns = (
|
||||
onMuteComplete={onMuteComplete}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
onTriageDetailLoadAction={onTriageDetailLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
|
||||
@@ -9,12 +9,13 @@ vi.mock("@/actions/resources", () => ({
|
||||
getResourceDrawerData: getResourceDrawerDataMock,
|
||||
}));
|
||||
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import type { ResourceFinding } from "@/types/resources";
|
||||
|
||||
import type { ResourceFinding } from "./resource-findings-columns";
|
||||
import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
|
||||
|
||||
function makeTriageSummary(
|
||||
@@ -41,7 +42,7 @@ function makeFinding(overrides?: Partial<ResourceFinding>): ResourceFinding {
|
||||
id: "finding-1",
|
||||
triage: makeTriageSummary(),
|
||||
attributes: {
|
||||
status: "FAIL",
|
||||
status: FINDING_STATUS.MANUAL,
|
||||
severity: "critical",
|
||||
muted: false,
|
||||
muted_reason: undefined,
|
||||
@@ -94,22 +95,22 @@ describe("useResourceDrawerBootstrap", () => {
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
note: "Investigating",
|
||||
manualPassEvidence: "Verified by the control owner.",
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.findingsData[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
hasVisibleNote: true,
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
manualPassProvenance: "Manually verified",
|
||||
}),
|
||||
);
|
||||
expect(result.current.findingsData[0]?.attributes.status).toBe("PASS");
|
||||
expect(getResourceDrawerDataMock).toHaveBeenCalledTimes(loadCount);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,7 @@ import { applyOptimisticFindingTriageRowsUpdate } from "@/lib/finding-triage";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
|
||||
import { ResourceFinding } from "./resource-findings-columns";
|
||||
import type { ResourceFinding } from "@/types/resources";
|
||||
|
||||
interface UseResourceDrawerBootstrapOptions {
|
||||
resourceId: string;
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
type FindingResourceRow,
|
||||
FINDINGS_ROW_TYPE,
|
||||
} from "@/types";
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageUpdateResult,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { useFindingGroupResourceState } from "./use-finding-group-resource-state";
|
||||
|
||||
@@ -288,8 +291,9 @@ describe("useFindingGroupResourceState", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
let updateResult: FindingTriageUpdateResult | void = undefined;
|
||||
await act(async () => {
|
||||
await result.current.updateTriageOptimistically(
|
||||
updateResult = await result.current.updateTriageOptimistically(
|
||||
{
|
||||
findingId: "finding-1",
|
||||
findingUid: "finding-uid-1",
|
||||
@@ -299,7 +303,9 @@ describe("useFindingGroupResourceState", () => {
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: true,
|
||||
},
|
||||
async () => undefined,
|
||||
async () => ({
|
||||
manualPassExpiresAt: "2026-10-28T12:00:00Z",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -310,5 +316,64 @@ describe("useFindingGroupResourceState", () => {
|
||||
mutedReason: "Existing mute rule",
|
||||
}),
|
||||
);
|
||||
expect(updateResult).toEqual({
|
||||
manualPassExpiresAt: "2026-10-28T12:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows effective Pass while a grouped manual pass update is pending", async () => {
|
||||
// Given
|
||||
const manualResource = {
|
||||
...findingResource("MANUAL"),
|
||||
triage: {
|
||||
findingId: "finding-1",
|
||||
findingUid: "finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
},
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useFindingGroupResourceState({
|
||||
group,
|
||||
filters: {},
|
||||
hasHistoricalData: false,
|
||||
}),
|
||||
);
|
||||
const onSetResources = useFindingGroupResourcesMock.mock.calls[0][0]
|
||||
.onSetResources as (
|
||||
resources: FindingResourceRow[],
|
||||
hasMore: boolean,
|
||||
) => void;
|
||||
await act(async () => onSetResources([manualResource], false));
|
||||
let resolveUpdate: () => void = () => {};
|
||||
|
||||
// When
|
||||
let updatePromise: Promise<FindingTriageUpdateResult | void> | undefined;
|
||||
act(() => {
|
||||
updatePromise = result.current.updateTriageOptimistically(
|
||||
{
|
||||
findingId: "finding-1",
|
||||
findingUid: "finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "Verified by the control owner.",
|
||||
},
|
||||
() => new Promise<void>((resolve) => (resolveUpdate = resolve)),
|
||||
);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.resources[0]?.status).toBe("PASS");
|
||||
|
||||
resolveUpdate();
|
||||
await act(async () => updatePromise);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,15 @@ import { applyDefaultMutedFilter } from "@/lib";
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
isManualPassTriageUpdate,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import type {
|
||||
FindingTriageUpdateResult,
|
||||
UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
interface UseFindingGroupResourceStateOptions {
|
||||
group: FindingGroupRow;
|
||||
@@ -45,8 +50,10 @@ interface UseFindingGroupResourceStateReturn {
|
||||
resolveSelectedFindingIds: (ids: string[]) => Promise<string[]>;
|
||||
updateTriageOptimistically: (
|
||||
input: UpdateFindingTriageInput,
|
||||
updateAction: (input: UpdateFindingTriageInput) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
updateAction: (
|
||||
input: UpdateFindingTriageInput,
|
||||
) => Promise<FindingTriageUpdateResult | void>,
|
||||
) => Promise<FindingTriageUpdateResult | void>;
|
||||
}
|
||||
|
||||
function getSelectedResources(
|
||||
@@ -94,9 +101,11 @@ export function useFindingGroupResourceState({
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(optimistic);
|
||||
const shouldSetTriageMuteReason =
|
||||
shouldMarkMuted && optimistic.isMuted !== true;
|
||||
const isManualPass = isManualPassTriageUpdate(optimistic);
|
||||
|
||||
return {
|
||||
...resource,
|
||||
status: isManualPass ? FINDING_STATUS.PASS : resource.status,
|
||||
isMuted: shouldMarkMuted ? true : resource.isMuted,
|
||||
mutedReason: shouldSetTriageMuteReason
|
||||
? getOptimisticTriageMutedReason(optimistic.status!)
|
||||
@@ -270,13 +279,16 @@ export function useFindingGroupResourceState({
|
||||
|
||||
const updateTriageOptimistically = async (
|
||||
input: UpdateFindingTriageInput,
|
||||
updateAction: (input: UpdateFindingTriageInput) => Promise<void>,
|
||||
updateAction: (
|
||||
input: UpdateFindingTriageInput,
|
||||
) => Promise<FindingTriageUpdateResult | void>,
|
||||
) => {
|
||||
const optimisticToken = applyOptimisticTriageUpdate(input);
|
||||
try {
|
||||
await updateAction(input);
|
||||
const result = await updateAction(input);
|
||||
settleOptimisticTriageUpdate(input.findingId, optimisticToken);
|
||||
refresh();
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearOptimisticTriageUpdate(input.findingId, optimisticToken);
|
||||
refresh();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
MANUAL_PASS_PROVENANCE,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
applyOptimisticFindingTriageRowsUpdate,
|
||||
applyOptimisticFindingTriageRowUpdate,
|
||||
} from "./finding-triage";
|
||||
@@ -54,6 +57,57 @@ function makeFindingRow(overrides?: Partial<TestFindingRow>): TestFindingRow {
|
||||
}
|
||||
|
||||
describe("finding triage optimistic row updates", () => {
|
||||
it("should expose the note created by manual pass evidence", () => {
|
||||
// Given
|
||||
const triage = makeTriageSummary();
|
||||
|
||||
// When
|
||||
const result = applyOptimisticTriageSummaryUpdate(triage, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "Verified by the control owner.",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.hasVisibleNote).toBe(true);
|
||||
expect(result.notesCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should preserve the Resolved triage label during a manual attestation update", () => {
|
||||
// Given
|
||||
const finding = makeFindingRow({
|
||||
triage: makeTriageSummary({
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
}),
|
||||
});
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowUpdate(finding, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
manualPassEvidence: "Verified by the control owner.",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
rawFindingStatus: FINDING_STATUS.MANUAL,
|
||||
manualPassProvenance: MANUAL_PASS_PROVENANCE,
|
||||
}),
|
||||
);
|
||||
expect(result.attributes.status).toBe(FINDING_STATUS.PASS);
|
||||
});
|
||||
|
||||
it("should patch matching finding row triage and muted attributes", () => {
|
||||
// Given
|
||||
const finding = makeFindingRow();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { FINDING_STATUS } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
MANUAL_PASS_PROVENANCE,
|
||||
type FindingTriageSummary,
|
||||
isMutelistShortcutStatus,
|
||||
type UpdateFindingTriageInput,
|
||||
@@ -8,6 +11,7 @@ import {
|
||||
interface FindingTriageRowAttributes {
|
||||
muted?: boolean;
|
||||
muted_reason?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface FindingTriageRow {
|
||||
@@ -29,21 +33,33 @@ export const getOptimisticTriageMutedReason = (
|
||||
): string =>
|
||||
`Finding triage status changed to ${FINDING_TRIAGE_STATUS_LABELS[status]}.`;
|
||||
|
||||
export const isManualPassTriageUpdate = (
|
||||
input: UpdateFindingTriageInput,
|
||||
): boolean =>
|
||||
input.status === FINDING_TRIAGE_STATUS.RESOLVED &&
|
||||
Boolean(input.manualPassEvidence);
|
||||
|
||||
export const applyOptimisticTriageSummaryUpdate = (
|
||||
triage: FindingTriageSummary,
|
||||
input: UpdateFindingTriageInput,
|
||||
): FindingTriageSummary => {
|
||||
const noteWasUpdated = Object.prototype.hasOwnProperty.call(input, "note");
|
||||
const manualPassHasEvidence = Boolean(input.manualPassEvidence?.trim());
|
||||
const noteWasUpdated =
|
||||
Object.prototype.hasOwnProperty.call(input, "note") ||
|
||||
manualPassHasEvidence;
|
||||
const noteHasContent =
|
||||
typeof input.note === "string" && input.note.length > 0;
|
||||
(typeof input.note === "string" && input.note.length > 0) ||
|
||||
manualPassHasEvidence;
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...triage,
|
||||
...(input.status
|
||||
? {
|
||||
status: input.status,
|
||||
label: FINDING_TRIAGE_STATUS_LABELS[input.status],
|
||||
manualPassProvenance: input.manualPassEvidence
|
||||
? MANUAL_PASS_PROVENANCE
|
||||
: triage.manualPassProvenance,
|
||||
isMuted: shouldMarkMuted ? true : triage.isMuted,
|
||||
}
|
||||
: {}),
|
||||
@@ -67,12 +83,14 @@ export const applyOptimisticFindingTriageRowUpdate = <
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
const isManualPass = isManualPassTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
|
||||
attributes: {
|
||||
...finding.attributes,
|
||||
status: isManualPass ? FINDING_STATUS.PASS : finding.attributes.status,
|
||||
muted: shouldMarkMuted ? true : finding.attributes.muted,
|
||||
muted_reason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
|
||||
@@ -583,6 +583,7 @@ export interface FindingProps {
|
||||
uid: string;
|
||||
delta: FindingDelta;
|
||||
status: FindingStatus;
|
||||
raw_status?: FindingStatus;
|
||||
status_extended: string;
|
||||
severity: Severity;
|
||||
check_id: string;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { FindingStatus } from "./components";
|
||||
import type { ProviderType } from "./providers";
|
||||
|
||||
export const FINDING_TRIAGE_STATUS = {
|
||||
OPEN: "open",
|
||||
UNDER_REVIEW: "under_review",
|
||||
@@ -21,6 +24,8 @@ export const FINDING_TRIAGE_STATUS_LABELS = {
|
||||
[FINDING_TRIAGE_STATUS.REOPENED]: "Reopened",
|
||||
} as const satisfies Record<FindingTriageStatus, string>;
|
||||
|
||||
export const MANUAL_PASS_PROVENANCE = "Manually verified" as const;
|
||||
|
||||
export const FINDING_TRIAGE_MANUAL_STATUS_VALUES = [
|
||||
FINDING_TRIAGE_STATUS.OPEN,
|
||||
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
@@ -32,6 +37,14 @@ export const FINDING_TRIAGE_MANUAL_STATUS_VALUES = [
|
||||
export type FindingTriageManualStatus =
|
||||
(typeof FINDING_TRIAGE_MANUAL_STATUS_VALUES)[number];
|
||||
|
||||
export const FINDING_TRIAGE_MODAL_STATUS_VALUES = [
|
||||
...FINDING_TRIAGE_MANUAL_STATUS_VALUES,
|
||||
FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
] as const;
|
||||
|
||||
export type FindingTriageModalStatus =
|
||||
(typeof FINDING_TRIAGE_MODAL_STATUS_VALUES)[number];
|
||||
|
||||
export const FINDING_TRIAGE_AUTOMATION_STATUS_VALUES = [
|
||||
FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
FINDING_TRIAGE_STATUS.REOPENED,
|
||||
@@ -92,6 +105,8 @@ export interface FindingTriageSummary {
|
||||
label: string;
|
||||
hasVisibleNote: boolean;
|
||||
isMuted: boolean;
|
||||
rawFindingStatus?: FindingStatus | null;
|
||||
manualPassProvenance?: typeof MANUAL_PASS_PROVENANCE | null;
|
||||
canEdit: boolean;
|
||||
disabledReason?: FindingTriageDisabledReason;
|
||||
billingHref: string;
|
||||
@@ -101,21 +116,68 @@ export interface FindingTriageDetail extends FindingTriageSummary {
|
||||
noteId: string | null;
|
||||
noteBody: string;
|
||||
maxNoteLength: typeof FINDING_TRIAGE_NOTE_MAX_LENGTH;
|
||||
rawFindingStatus: FindingStatus | null;
|
||||
manualPassActive: boolean | null;
|
||||
manualPassEvidence: string | null;
|
||||
manualPassCreatedByName: string | null;
|
||||
manualPassCreatedAt: string | null;
|
||||
manualPassExpiresAt: string | null;
|
||||
manualPassDeactivatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateFindingTriageInput {
|
||||
export interface FindingTriageUpdateResult {
|
||||
manualPassExpiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface FindingTriageContext {
|
||||
title: string;
|
||||
resource?: string;
|
||||
provider?: string;
|
||||
providerType?: ProviderType;
|
||||
}
|
||||
|
||||
interface FindingTriageUpdateBase {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
noteId?: string | null;
|
||||
status?: FindingTriageManualStatus;
|
||||
previousStatus?: FindingTriageStatus;
|
||||
isMuted?: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface StandardFindingTriageUpdate extends FindingTriageUpdateBase {
|
||||
status?: FindingTriageManualStatus;
|
||||
note?: string;
|
||||
manualPassEvidence?: never;
|
||||
}
|
||||
|
||||
interface ManualPassFindingTriageUpdate extends FindingTriageUpdateBase {
|
||||
status: typeof FINDING_TRIAGE_STATUS.RESOLVED;
|
||||
previousStatus: FindingTriageManualStatus;
|
||||
manualPassEvidence: string;
|
||||
note?: never;
|
||||
}
|
||||
|
||||
export type UpdateFindingTriageInput =
|
||||
| StandardFindingTriageUpdate
|
||||
| ManualPassFindingTriageUpdate;
|
||||
|
||||
export interface FindingTriageLoadedNote {
|
||||
noteId: string;
|
||||
noteBody: string;
|
||||
}
|
||||
export type FindingTriageUpdateHandler = (
|
||||
input: UpdateFindingTriageInput,
|
||||
) =>
|
||||
| FindingTriageUpdateResult
|
||||
| void
|
||||
| Promise<FindingTriageUpdateResult | void>;
|
||||
|
||||
export type FindingTriageDetailLoadHandler = (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageDetail>;
|
||||
|
||||
export type FindingTriageNoteLoadHandler = (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
import type { FindingDelta, FindingStatus, Severity } from "./components";
|
||||
import type { FindingTriageSummary } from "./findings-triage";
|
||||
|
||||
export interface ResourceFindingCheckMetadata {
|
||||
checktitle?: string;
|
||||
}
|
||||
|
||||
export interface ResourceFindingAttributes {
|
||||
status: FindingStatus;
|
||||
severity: Severity;
|
||||
muted?: boolean;
|
||||
muted_reason?: string;
|
||||
delta?: Exclude<FindingDelta, null>;
|
||||
updated_at?: string;
|
||||
check_metadata?: ResourceFindingCheckMetadata;
|
||||
}
|
||||
|
||||
export interface ResourceFinding {
|
||||
type: "findings";
|
||||
id: string;
|
||||
triage?: FindingTriageSummary;
|
||||
attributes: ResourceFindingAttributes;
|
||||
}
|
||||
|
||||
export interface ResourceProps {
|
||||
type: "resources";
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user