feat(ui): add findings triage (#11704)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2026-07-01 17:55:33 +02:00
committed by GitHub
parent 050a5915ca
commit 587187419f
62 changed files with 6359 additions and 464 deletions
@@ -1,10 +1,26 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
} from "@/types/findings-triage";
import {
adaptFindingGroupResourcesResponse,
adaptFindingGroupsResponse,
} from "./finding-groups.adapter";
const expectNoRawTriageTransportKeys = (value: Record<string, unknown>) => {
expect(value).not.toHaveProperty("triage_status");
expect(value).not.toHaveProperty("triage_has_note");
expect(value).not.toHaveProperty("attributes");
expect(value).not.toHaveProperty("relationships");
};
afterEach(() => {
vi.unstubAllEnvs();
});
// ---------------------------------------------------------------------------
// Fix 1: adaptFindingGroupsResponse — unknown + type guard
// ---------------------------------------------------------------------------
@@ -21,17 +37,6 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
expect(result).toEqual([]);
});
it("should return [] when apiResponse has no data property", () => {
// Given
const input = { meta: { total: 0 } };
// When
const result = adaptFindingGroupsResponse(input);
// Then
expect(result).toEqual([]);
});
it("should return [] when data is not an array", () => {
// Given
const input = { data: "not-an-array" };
@@ -43,28 +48,6 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
expect(result).toEqual([]);
});
it("should return [] when data is null", () => {
// Given
const input = { data: null };
// When
const result = adaptFindingGroupsResponse(input);
// Then
expect(result).toEqual([]);
});
it("should return [] when apiResponse is undefined", () => {
// Given
const input = undefined;
// When
const result = adaptFindingGroupsResponse(input);
// Then
expect(result).toEqual([]);
});
it("should return mapped rows for valid data", () => {
// Given
const input = {
@@ -106,6 +89,10 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
failing_since: null,
finding_id: "group-finding-id-1",
finding_uid: "group-finding-uid-1",
triage_status: "risk_accepted",
triage_has_note: true,
},
},
],
@@ -121,6 +108,10 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
expect(result[0].muted).toBe(true);
expect(result[0].manualCount).toBe(1);
expect(result[0].newFailMutedCount).toBe(1);
expect(result[0]).not.toHaveProperty("triage");
expectNoRawTriageTransportKeys(
result[0] as unknown as Record<string, unknown>,
);
});
});
@@ -137,14 +128,6 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
expect(result).toEqual([]);
});
it("should return [] when apiResponse has no data property", () => {
// Given/When
const result = adaptFindingGroupResourcesResponse({ meta: {} }, "check-1");
// Then
expect(result).toEqual([]);
});
it("should return [] when data is not an array", () => {
// Given/When
const result = adaptFindingGroupResourcesResponse({ data: {} }, "check-1");
@@ -153,12 +136,206 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
expect(result).toEqual([]);
});
it("should return [] when apiResponse is undefined", () => {
// Given/When
const result = adaptFindingGroupResourcesResponse(undefined, "check-1");
it("should keep resource rows with valid attributes even when JSON:API type differs", () => {
// Given
const input = {
data: [
{
id: "resource-row-1",
type: "findings",
attributes: {
finding_id: "real-finding-uuid",
finding_uid: "prowler-finding-uid-1",
triage_status: "remediating",
triage_notes_count: 1,
resource: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
service: "s3",
region: "us-east-1",
type: "Bucket",
resource_group: "default",
},
provider: {
type: "aws",
uid: "123456789",
alias: "production",
},
status: "FAIL",
severity: "high",
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
},
},
],
};
// When
const result = adaptFindingGroupResourcesResponse(input, "check-1");
// Then
expect(result).toEqual([]);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
findingId: "real-finding-uuid",
resourceName: "my-bucket",
triage: expect.objectContaining({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
hasVisibleNote: true,
}),
}),
);
});
it("should skip malformed resource entries inside a data array", () => {
// Given
const input = {
data: [
null,
"bad-entry",
{
id: "resource-row-1",
type: "finding-group-resources",
attributes: {
finding_id: "real-finding-uuid",
resource: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
service: "s3",
region: "us-east-1",
type: "Bucket",
resource_group: "default",
},
provider: {
type: "aws",
uid: "123456789",
alias: "production",
},
status: "FAIL",
severity: "high",
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
},
},
],
};
// When
const result = adaptFindingGroupResourcesResponse(input, "check-1");
// Then
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
findingId: "real-finding-uuid",
resourceName: "my-bucket",
}),
);
});
it("should attach adapter-produced triage DTOs to finding-level resource rows", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const input = {
data: [
{
id: "resource-row-1",
type: "finding-group-resources",
attributes: {
finding_id: "real-finding-uuid",
finding_uid: "prowler-finding-uid-1",
triage_status: "under_review",
triage_has_note: true,
resource: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
service: "s3",
region: "us-east-1",
type: "Bucket",
resource_group: "default",
},
provider: {
type: "aws",
uid: "123456789",
alias: "production",
},
status: "FAIL",
muted: false,
delta: "new",
severity: "critical",
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
},
},
],
};
// When
const [row] = adaptFindingGroupResourcesResponse(input, "s3_check");
// Then
expect(row.triage).toEqual(
expect.objectContaining({
findingId: "real-finding-uuid",
findingUid: "prowler-finding-uid-1",
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
canEdit: true,
}),
);
expectNoRawTriageTransportKeys(
row.triage as unknown as Record<string, unknown>,
);
});
it("should leave triage editing disabled until a real capability is provided", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const input = {
data: [
{
id: "resource-row-1",
type: "finding-group-resources",
attributes: {
finding_id: "real-finding-uuid",
finding_uid: "prowler-finding-uid-1",
triage_status: "open",
triage_has_note: false,
resource: {
uid: "arn:aws:s3:::my-bucket",
name: "my-bucket",
service: "s3",
region: "us-east-1",
type: "Bucket",
resource_group: "default",
},
provider: {
type: "aws",
uid: "123456789",
alias: "production",
},
status: "FAIL",
muted: false,
delta: "new",
severity: "critical",
first_seen_at: null,
last_seen_at: "2024-01-01T00:00:00Z",
},
},
],
};
// When
const [row] = adaptFindingGroupResourcesResponse(input, "s3_check");
// Then
expect(row.triage).toEqual(
expect.objectContaining({
canEdit: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
}),
);
});
it("should return mapped rows for valid data", () => {
@@ -1,3 +1,5 @@
import { adaptFindingTriageSummariesResponse } from "@/actions/findings/findings-triage.adapter";
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
import type {
FindingGroupRow,
FindingResourceRow,
@@ -72,6 +74,7 @@ export function adaptFindingGroupsResponse(
}
const data = (apiResponse as { data: FindingGroupApiItem[] }).data;
return data.map((item) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.GROUP,
@@ -146,6 +149,9 @@ interface FindingGroupResourceAttributes {
first_seen_at: string | null;
last_seen_at: string | null;
muted_reason?: string | null;
finding_uid?: string;
triage_status?: string;
triage_has_note?: boolean;
}
interface FindingGroupResourceApiItem {
@@ -154,6 +160,26 @@ interface FindingGroupResourceApiItem {
attributes: FindingGroupResourceAttributes;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object";
const isFindingGroupResourceApiItem = (
value: unknown,
): value is FindingGroupResourceApiItem => {
if (!isRecord(value) || typeof value.id !== "string") {
return false;
}
const attributes = value.attributes;
return (
isRecord(attributes) &&
typeof attributes.finding_id === "string" &&
isRecord(attributes.resource) &&
isRecord(attributes.provider)
);
};
/**
* Transforms the API response for finding group resources (drill-down)
* into FindingResourceRow[].
@@ -171,8 +197,15 @@ export function adaptFindingGroupResourcesResponse(
return [];
}
const data = (apiResponse as { data: FindingGroupResourceApiItem[] }).data;
return data.map((item) => ({
const data = (apiResponse as { data: unknown[] }).data.filter(
isFindingGroupResourceApiItem,
);
const triageSummaries = adaptFindingTriageSummariesResponse(
{ ...apiResponse, data },
getFindingTriageAdapterOptions(),
);
return data.map((item, index) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.RESOURCE,
findingId: item.attributes.finding_id || item.id,
@@ -194,5 +227,6 @@ export function adaptFindingGroupResourcesResponse(
mutedReason: item.attributes.muted_reason || undefined,
firstSeenAt: item.attributes.first_seen_at,
lastSeenAt: item.attributes.last_seen_at,
triage: triageSummaries[index],
}));
}
@@ -22,6 +22,8 @@ vi.mock("next/navigation", () => ({
// Import after mocks
// ---------------------------------------------------------------------------
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import { adaptFindingsByResourceResponse } from "./findings-by-resource.adapter";
// ---------------------------------------------------------------------------
@@ -43,22 +45,6 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
expect(result).toEqual([]);
});
it("should return [] when apiResponse is undefined", () => {
// Given/When
const result = adaptFindingsByResourceResponse(undefined);
// Then
expect(result).toEqual([]);
});
it("should return [] when apiResponse has no data property", () => {
// Given/When
const result = adaptFindingsByResourceResponse({ meta: {} });
// Then
expect(result).toEqual([]);
});
it("should return [] when data is not an array", () => {
// Given/When
const result = adaptFindingsByResourceResponse({ data: "bad" });
@@ -75,14 +61,6 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
expect(result).toEqual([]);
});
it("should return [] when data is a number", () => {
// Given/When
const result = adaptFindingsByResourceResponse({ data: 42 });
// Then
expect(result).toEqual([]);
});
it("should return mapped findings for valid minimal data", () => {
// Given — minimal valid JSON:API shape
const input = {
@@ -194,8 +172,8 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
expect(result[0].resourceMetadata).toBeNull();
});
it("should normalize a single finding response into a one-item drawer array", () => {
// Given getFindingById returns a single JSON:API resource object
it("should preserve triage summary fields for a single finding response", () => {
// Given - getFindingById returns a single finding with provisional triage fields
const input = {
data: {
id: "finding-1",
@@ -204,6 +182,10 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
check_id: "s3_check",
status: "FAIL",
severity: "critical",
triage_id: "triage-1",
triage_status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
triage_notes_count: 1,
triage_has_note: true,
check_metadata: {
checktitle: "S3 Check",
},
@@ -220,8 +202,16 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
const result = adaptFindingsByResourceResponse(input);
// Then
expect(result).toHaveLength(1);
expect(result[0].id).toBe("finding-1");
expect(result[0].checkTitle).toBe("S3 Check");
expect(result[0].triage).toEqual(
expect.objectContaining({
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 1,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
}),
);
});
});
@@ -1,5 +1,8 @@
import { adaptFindingTriageSummariesResponse } from "@/actions/findings/findings-triage.adapter";
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
import { createDict } from "@/lib";
import type { ProviderType, Severity } from "@/types";
import type { FindingTriageSummary } from "@/types/findings-triage";
export interface RemediationRecommendation {
text: string;
@@ -49,6 +52,7 @@ export interface ResourceDrawerFinding {
mutedReason: string | null;
firstSeenAt: string | null;
updatedAt: string | null;
triage?: FindingTriageSummary;
// Resource
resourceId: string;
resourceUid: string;
@@ -195,8 +199,12 @@ export function adaptFindingsByResourceResponse(
const findings = Array.isArray(apiResponse.data)
? apiResponse.data
: [apiResponse.data];
const triageSummaries = adaptFindingTriageSummariesResponse(
{ ...apiResponse, data: findings },
getFindingTriageAdapterOptions(),
);
return findings.map((item) => {
return findings.map((item, index) => {
const attrs = item.attributes;
const meta = (attrs.check_metadata || {}) as Record<string, unknown>;
const remediationRaw = meta.remediation as
@@ -254,6 +262,7 @@ export function adaptFindingsByResourceResponse(
mutedReason: attrs.muted_reason || null,
firstSeenAt: attrs.first_seen_at || null,
updatedAt: attrs.updated_at || null,
triage: triageSummaries[index],
// Resource
resourceId: resourceRel?.id || "",
resourceUid: (resourceAttrs.uid as string | undefined) || "-",
@@ -0,0 +1,418 @@
import { describe, expect, it } from "vitest";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
FINDING_TRIAGE_STATUS_LABELS,
} from "@/types/findings-triage";
import {
adaptFindingTriageDetailResponse,
adaptFindingTriageSummariesResponse,
adaptLatestFindingTriageNote,
attachFindingTriageSummariesToResponse,
} from "./findings-triage.adapter";
import {
allProvisionalTriageStatusFindings,
findingTriageDetailResponse,
flatFindingWithAcceptedRiskTriage,
flatFindingWithNotePresenceOnly,
flatFindingWithUnderReviewTriage,
flatPassFindingWithoutPersistedTriage,
} from "./findings-triage.fixtures";
const expectNoRawTransportKeys = (value: Record<string, unknown>) => {
expect(value).not.toHaveProperty("attributes");
expect(value).not.toHaveProperty("relationships");
expect(value).not.toHaveProperty("included");
expect(value).not.toHaveProperty("triage_status");
expect(value).not.toHaveProperty("triage_has_note");
expect(value).not.toHaveProperty("triage_note");
expect(value).not.toHaveProperty("current_note");
expect(value).not.toHaveProperty("source");
expect(value).not.toHaveProperty("updated_by");
expect(value).not.toHaveProperty("inserted_at");
expect(value).not.toHaveProperty("updated_at");
};
describe("provisional findings triage contract fixtures", () => {
it("should document every triage status the provisional contract can return", () => {
// Given
const input = {
data: allProvisionalTriageStatusFindings,
};
// When
const result = adaptFindingTriageSummariesResponse(input, {
canEdit: true,
});
// Then
expect(result).toHaveLength(7);
expect(result.map((summary) => summary.status)).toEqual([
FINDING_TRIAGE_STATUS.OPEN,
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
FINDING_TRIAGE_STATUS.REMEDIATING,
FINDING_TRIAGE_STATUS.RESOLVED,
FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
FINDING_TRIAGE_STATUS.REOPENED,
]);
expect(result.map((summary) => summary.label)).toEqual([
"Open",
"Under Review",
"Remediating",
"Resolved",
"Risk Accepted",
"False Positive",
"Reopened",
]);
expect(result[0].label).toBe(
FINDING_TRIAGE_STATUS_LABELS[FINDING_TRIAGE_STATUS.OPEN],
);
});
it("should model table note presence without requiring note previews", () => {
// Given
const input = {
data: [flatFindingWithNotePresenceOnly],
};
// When
const [summary] = adaptFindingTriageSummariesResponse(input);
// Then
expect(summary).toEqual(
expect.objectContaining({
findingId: "finding-note-presence-1",
findingUid: "prowler-finding-note-presence-uid-1",
hasVisibleNote: true,
}),
);
expect(JSON.stringify(summary)).not.toContain("triage_note");
expect(JSON.stringify(summary)).not.toContain("current_note");
});
it("should model modal note detail as a separate detail payload", () => {
// Given / When
const detail = adaptFindingTriageDetailResponse(
findingTriageDetailResponse,
);
// Then
expect(detail.noteBody).toBe("Current note visible only inside the modal.");
expect(detail.hasVisibleNote).toBe(true);
expect(detail.maxNoteLength).toBe(500);
});
it("should model disabled non-paying state through adapter options only", () => {
// Given
const input = {
data: [flatFindingWithUnderReviewTriage],
};
// When
const [summary] = adaptFindingTriageSummariesResponse(input, {
canEdit: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
});
// Then
expect(summary.canEdit).toBe(false);
expect(summary.disabledReason).toBe(
FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
);
expect(summary.billingHref).toBe("https://prowler.com/pricing");
});
});
describe("adaptLatestFindingTriageNote", () => {
it("should adapt the newest note from a JSON:API collection", () => {
// Given
const response = {
data: [
{
id: "note-latest",
type: "finding-triage-notes",
attributes: {
body: "Latest investigation note",
},
},
],
};
// When
const result = adaptLatestFindingTriageNote(response);
// Then
expect(result).toEqual({
noteId: "note-latest",
noteBody: "Latest investigation note",
});
});
it("should return null when the response has no usable note", () => {
expect(adaptLatestFindingTriageNote({ data: [] })).toBeNull();
expect(
adaptLatestFindingTriageNote({ data: [{ id: "note-1" }] }),
).toBeNull();
});
});
describe("adaptFindingTriageSummariesResponse", () => {
it("should return [] when the provisional API response is malformed", () => {
// Given
const input = { meta: { count: 0 } };
// When
const result = adaptFindingTriageSummariesResponse(input);
// Then
expect(result).toEqual([]);
});
it("should skip malformed entries inside a data array", () => {
// Given
const input = {
data: [null, "bad-entry", flatFindingWithUnderReviewTriage],
};
// When
const result = adaptFindingTriageSummariesResponse(input);
// Then
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
findingId: "finding-1",
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
}),
);
});
it("should not shift attached summaries after malformed entries", () => {
// Given
const validWithoutTriage = {
id: "resource-row-1",
type: "finding-group-resources",
attributes: {
finding_id: "finding-1",
finding_uid: "prowler-finding-uid-1",
triage_status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
triage_notes_count: 1,
status: "FAIL",
},
};
const laterValidWithoutTriage = {
id: "resource-row-2",
type: "finding-group-resources",
attributes: {
finding_id: "finding-2",
finding_uid: "prowler-finding-uid-2",
triage_status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
triage_notes_count: 0,
status: "MUTED",
},
};
const input = {
data: [validWithoutTriage, null, laterValidWithoutTriage],
};
// When
const result = attachFindingTriageSummariesToResponse(input);
// Then
expect(result?.data[0]).toEqual(
expect.objectContaining({
triage: expect.objectContaining({
findingId: "finding-1",
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
}),
}),
);
expect(result?.data[1]).toBeNull();
expect(result?.data[2]).toEqual(
expect.objectContaining({
triage: expect.objectContaining({
findingId: "finding-2",
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
}),
}),
);
});
it("should use triage_notes_count from the resource triage API contract", () => {
// Given
const input = {
data: [
{
id: "resource-row-1",
type: "finding-group-resources",
attributes: {
finding_id: "finding-1",
finding_uid: "prowler-finding-uid-1",
triage_status: FINDING_TRIAGE_STATUS.REMEDIATING,
triage_notes_count: 5,
status: "FAIL",
},
},
{
id: "resource-row-2",
type: "finding-group-resources",
attributes: {
finding_id: "finding-2",
finding_uid: "prowler-finding-uid-2",
triage_status: FINDING_TRIAGE_STATUS.OPEN,
triage_notes_count: 0,
status: "FAIL",
},
},
],
};
// When
const result = adaptFindingTriageSummariesResponse(input);
// Then
expect(result).toHaveLength(2);
expect(result[0]).toEqual(
expect.objectContaining({
hasVisibleNote: true,
status: FINDING_TRIAGE_STATUS.REMEDIATING,
}),
);
expect(result[1]).toEqual(
expect.objectContaining({
hasVisibleNote: false,
status: FINDING_TRIAGE_STATUS.OPEN,
}),
);
});
it("should mark triage as muted when resource status is MUTED", () => {
// Given
const input = {
data: [
{
id: "resource-row-muted-1",
type: "finding-group-resources",
attributes: {
finding_id: "finding-muted-1",
finding_uid: "prowler-finding-muted-uid-1",
triage_status: FINDING_TRIAGE_STATUS.OPEN,
triage_notes_count: 0,
status: "MUTED",
},
},
],
};
// When
const [summary] = adaptFindingTriageSummariesResponse(input);
// Then
expect(summary).toEqual(
expect.objectContaining({
findingId: "finding-muted-1",
isMuted: true,
status: FINDING_TRIAGE_STATUS.OPEN,
}),
);
});
it("should normalize flat provisional finding fields into domain triage summaries", () => {
// Given
const input = {
data: [flatFindingWithUnderReviewTriage],
};
// When
const result = adaptFindingTriageSummariesResponse(input, {
canEdit: true,
});
// Then
expect(result).toEqual([
expect.objectContaining({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
canEdit: true,
billingHref: "https://prowler.com/pricing",
}),
]);
});
it("should fallback from scan status when no persisted triage status exists", () => {
// Given
const input = {
data: [flatPassFindingWithoutPersistedTriage],
};
// When
const result = adaptFindingTriageSummariesResponse(input);
// Then
expect(result[0]).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.RESOLVED,
label: "Resolved",
hasVisibleNote: false,
}),
);
});
it("should keep raw provisional fields out of table component DTOs", () => {
// Given
const input = {
data: [flatFindingWithAcceptedRiskTriage],
};
// When
const [summary] = adaptFindingTriageSummariesResponse(input);
// Then
expectNoRawTransportKeys(summary as unknown as Record<string, unknown>);
expect(JSON.stringify(summary)).not.toContain("Accepted risk note body");
});
});
describe("adaptFindingTriageDetailResponse", () => {
it("should normalize provisional detail payloads into modal DTOs", () => {
// Given
const input = findingTriageDetailResponse;
// When
const detail = adaptFindingTriageDetailResponse(input, {
canEdit: true,
});
// Then
expect(detail).toEqual(
expect.objectContaining({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
label: "Risk Accepted",
hasVisibleNote: true,
canEdit: true,
noteBody: "Current note visible only inside the modal.",
maxNoteLength: 500,
privacyCopy: "This note is only visible to your team.",
}),
);
});
it("should keep raw provisional fields out of modal component DTOs", () => {
// Given
const input = findingTriageDetailResponse;
// When
const detail = adaptFindingTriageDetailResponse(input);
// Then
expectNoRawTransportKeys(detail as unknown as Record<string, unknown>);
});
});
@@ -0,0 +1,220 @@
import {
FINDING_TRIAGE_BILLING_HREF,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageDetail,
type FindingTriageDisabledReason,
type FindingTriageLoadedNote,
type FindingTriageStatus,
type FindingTriageSummary,
} from "@/types/findings-triage";
// API/backend triage implementation is external to this UI slice. Keep final
// contract churn isolated here (and the server action transport) so table/modal
// components continue consuming stable domain DTOs.
interface FindingTriageAdapterOptions {
canEdit?: boolean;
disabledReason?: FindingTriageDisabledReason;
billingHref?: string;
}
interface FindingTriageAttributes {
finding_id?: string;
finding_uid?: string;
uid?: string;
triage_id?: string;
triage_notes_count?: number;
triage_status?: unknown;
triage_has_note?: boolean;
status?: unknown;
muted?: boolean;
body?: unknown;
current_note?: string;
note?: string;
has_note?: boolean;
note_id?: string;
}
interface JsonApiResource {
id?: string;
attributes?: FindingTriageAttributes;
}
interface NormalizedTriageFields {
status: FindingTriageStatus;
hasVisibleNote: boolean;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object";
const isJsonApiResource = (value: unknown): value is JsonApiResource =>
isRecord(value) &&
(!("attributes" in value) ||
value.attributes === undefined ||
isRecord(value.attributes));
const isFindingTriageStatus = (value: unknown): value is FindingTriageStatus =>
typeof value === "string" &&
Object.values(FINDING_TRIAGE_STATUS).includes(value as FindingTriageStatus);
const fallbackStatusFromFindingStatus = (
findingStatus: unknown,
): FindingTriageStatus =>
findingStatus === "PASS"
? FINDING_TRIAGE_STATUS.RESOLVED
: FINDING_TRIAGE_STATUS.OPEN;
const normalizeTriageFields = (
finding: JsonApiResource,
): NormalizedTriageFields => {
const attributes = finding.attributes ?? {};
if (isFindingTriageStatus(attributes.triage_status)) {
return {
status: attributes.triage_status,
hasVisibleNote:
(typeof attributes.triage_notes_count === "number" &&
attributes.triage_notes_count >= 1) ||
attributes.triage_has_note === true,
};
}
return {
status: fallbackStatusFromFindingStatus(attributes.status),
hasVisibleNote: false,
};
};
const createSummary = (
finding: JsonApiResource,
triageFields: NormalizedTriageFields,
options: FindingTriageAdapterOptions,
): FindingTriageSummary => {
const attributes = finding.attributes ?? {};
const summary: FindingTriageSummary = {
findingId: attributes.finding_id || finding.id || "",
findingUid: attributes.uid || attributes.finding_uid || "",
triageId: attributes.triage_id || null,
notesCount: attributes.triage_notes_count ?? 0,
status: triageFields.status,
label: FINDING_TRIAGE_STATUS_LABELS[triageFields.status],
hasVisibleNote: triageFields.hasVisibleNote,
isMuted:
typeof attributes.muted === "boolean"
? attributes.muted
: attributes.status === "MUTED",
canEdit: options.canEdit ?? false,
billingHref: options.billingHref ?? FINDING_TRIAGE_BILLING_HREF,
};
if (options.disabledReason) {
summary.disabledReason = options.disabledReason;
}
return summary;
};
export function adaptFindingTriageSummariesResponse(
apiResponse: unknown,
options: FindingTriageAdapterOptions = {},
): FindingTriageSummary[] {
if (!isRecord(apiResponse) || !Array.isArray(apiResponse.data)) {
return [];
}
return apiResponse.data
.filter(isJsonApiResource)
.map((finding) =>
createSummary(finding, normalizeTriageFields(finding), options),
);
}
export function adaptLatestFindingTriageNote(
apiResponse: unknown,
): FindingTriageLoadedNote | null {
const latestNote =
isRecord(apiResponse) && Array.isArray(apiResponse.data)
? apiResponse.data.find(isJsonApiResource)
: undefined;
const noteId = latestNote?.id;
const noteBody = latestNote?.attributes?.body;
if (typeof noteId !== "string" || !noteId || typeof noteBody !== "string") {
return null;
}
return {
noteId,
noteBody,
};
}
export function attachFindingTriageSummariesToResponse<
T extends { data?: unknown },
>(
apiResponse: T | undefined,
options: FindingTriageAdapterOptions = {},
): T | undefined {
if (!apiResponse || !Array.isArray(apiResponse.data)) {
return apiResponse;
}
return {
...apiResponse,
data: apiResponse.data.map((item) =>
isJsonApiResource(item)
? {
...item,
triage: createSummary(item, normalizeTriageFields(item), options),
}
: item,
),
};
}
export function adaptFindingTriageDetailResponse(
apiResponse: unknown,
options: FindingTriageAdapterOptions = {},
): FindingTriageDetail {
const data =
isRecord(apiResponse) && isJsonApiResource(apiResponse.data)
? apiResponse.data
: undefined;
const attributes = data?.attributes ?? {};
const status = isFindingTriageStatus(attributes.status)
? attributes.status
: FINDING_TRIAGE_STATUS.OPEN;
const noteBody =
typeof attributes.current_note === "string"
? attributes.current_note
: typeof attributes.note === "string"
? attributes.note
: "";
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),
},
},
{
status,
hasVisibleNote: attributes.has_note === true || noteBody.length > 0,
},
options,
);
return {
...summary,
noteId: attributes.note_id || null,
noteBody,
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
};
}
@@ -0,0 +1,101 @@
// Provisional UI/API contract fixtures only. API implementation is external to this
// UI slice; when the final API lands, update the adapter/server-action seam instead
// of teaching table or modal components about the transport payload shape.
const createFlatFindingWithTriageStatus = (status: string, index: number) => ({
type: "findings",
id: `finding-contract-${index}`,
attributes: {
uid: `prowler-finding-contract-uid-${index}`,
status: "FAIL",
triage_status: status,
triage_has_note: false,
},
});
export const allProvisionalTriageStatusFindings = [
createFlatFindingWithTriageStatus("open", 1),
createFlatFindingWithTriageStatus("under_review", 2),
createFlatFindingWithTriageStatus("remediating", 3),
createFlatFindingWithTriageStatus("resolved", 4),
createFlatFindingWithTriageStatus("risk_accepted", 5),
createFlatFindingWithTriageStatus("false_positive", 6),
createFlatFindingWithTriageStatus("reopened", 7),
] as const;
export const flatFindingWithNotePresenceOnly = {
type: "findings",
id: "finding-note-presence-1",
attributes: {
uid: "prowler-finding-note-presence-uid-1",
status: "FAIL",
triage_id: "triage-note-presence-1",
triage_status: "under_review",
triage_notes_count: 1,
triage_has_note: true,
},
} as const;
export const flatFindingWithUnderReviewTriage = {
type: "findings",
id: "finding-1",
attributes: {
uid: "prowler-finding-uid-1",
status: "FAIL",
triage_id: "triage-note-presence-1",
triage_status: "under_review",
triage_notes_count: 1,
triage_has_note: true,
},
} as const;
export const flatPassFindingWithoutPersistedTriage = {
type: "findings",
id: "finding-pass-1",
attributes: {
uid: "prowler-finding-pass-uid-1",
status: "PASS",
triage_id: null,
triage_status: null,
triage_notes_count: 0,
triage_has_note: false,
},
} as const;
export const flatFindingWithAcceptedRiskTriage = {
type: "findings",
id: "finding-accepted-risk-1",
attributes: {
uid: "prowler-finding-accepted-risk-uid-1",
status: "FAIL",
triage_id: "triage-accepted-risk-1",
triage_status: "risk_accepted",
triage_notes_count: 1,
triage_has_note: true,
triage_note:
"Accepted risk note body that must never appear in a table DTO.",
source: "manual",
updated_by: "user-1",
inserted_at: "2026-06-01T10:00:00Z",
updated_at: "2026-06-01T10:05:00Z",
},
} as const;
export const findingTriageDetailResponse = {
data: {
type: "finding-triages",
id: "triage-detail-1",
attributes: {
finding_id: "finding-1",
finding_uid: "prowler-finding-uid-1",
status: "risk_accepted",
triage_notes_count: 1,
has_note: true,
note_id: "note-detail-1",
current_note: "Current note visible only inside the modal.",
source: "manual",
updated_by: "user-1",
inserted_at: "2026-06-03T10:00:00Z",
updated_at: "2026-06-03T10:05:00Z",
},
},
} as const;
@@ -0,0 +1,20 @@
import {
FINDING_TRIAGE_DISABLED_REASON,
type FindingTriageDisabledReason,
} from "@/types/findings-triage";
interface FindingTriageAdapterOptions {
canEdit: boolean;
disabledReason?: FindingTriageDisabledReason;
}
export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions {
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return {
canEdit: isCloudEnvironment,
...(isCloudEnvironment
? {}
: { disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY }),
};
}
+644
View File
@@ -0,0 +1,644 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
const {
createMuteRuleMock,
fetchMock,
getAuthHeadersMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
createMuteRuleMock: vi.fn(),
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/actions/mute-rules", () => ({
createMuteRule: createMuteRuleMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
const importActions = async () => import("./findings-triage");
describe("findings triage actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
createMuteRuleMock.mockResolvedValue({ success: "muted" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
});
it("should load notes through the persisted triage route when triageId exists", async () => {
// Given
const { loadLatestFindingTriageNote } = await importActions();
handleApiResponseMock.mockResolvedValue({
data: [
{
id: "note-1",
type: "finding-triage-notes",
attributes: { body: "Existing note" },
},
],
});
// When
const result = await loadLatestFindingTriageNote({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 1,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
isMuted: false,
canEdit: true,
billingHref: "https://prowler.com/pricing",
});
// Then
expect(result).toEqual({ noteId: "note-1", noteBody: "Existing note" });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/finding-triages/triage-1/notes",
expect.objectContaining({
headers: { Authorization: "Bearer token" },
}),
);
});
it("should load notes through the finding UID route when triageId is virtual", async () => {
// Given
const { loadLatestFindingTriageNote } = await importActions();
handleApiResponseMock.mockResolvedValue({
data: [
{
id: "note-1",
type: "finding-triage-notes",
attributes: { body: "Existing note" },
},
],
});
// When
await loadLatestFindingTriageNote({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: null,
notesCount: 1,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
isMuted: false,
canEdit: true,
billingHref: "https://prowler.com/pricing",
});
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes",
expect.any(Object),
);
});
it("should resolve findingUid from findingId before loading virtual triage notes", async () => {
// Given
const { loadLatestFindingTriageNote } = await importActions();
handleApiResponseMock
.mockResolvedValueOnce({
data: { attributes: { uid: "finding/stable/uid" } },
})
.mockResolvedValueOnce({
data: [
{
id: "note-1",
type: "finding-triage-notes",
attributes: { body: "Existing note" },
},
],
});
// When
await loadLatestFindingTriageNote({
findingId: "finding-snapshot-id",
findingUid: "",
triageId: null,
notesCount: 1,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
isMuted: false,
canEdit: true,
billingHref: "https://prowler.com/pricing",
});
// Then
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/findings/finding-snapshot-id",
expect.any(Object),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes",
expect.any(Object),
);
});
it("should send the first note with the status update through the triage route", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
note: "First note",
});
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
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.UNDER_REVIEW,
note: "First note",
},
},
}),
}),
);
});
it("should update an existing note through its note id", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 1,
noteId: "note-1",
note: "Updated note",
});
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
data: {
type: "finding-triage-notes",
attributes: {
body: "Updated note",
},
},
}),
}),
);
});
it("should delete an existing persisted note when it is cleared", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 1,
noteId: "note-1",
note: "",
});
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
{
method: "DELETE",
headers: { Authorization: "Bearer token" },
},
);
expect(fetchMock).not.toHaveBeenCalledWith(
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
expect.objectContaining({ method: "PATCH" }),
);
});
it("should update an existing note and send status-only triage patch", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 1,
noteId: "note-1",
status: FINDING_TRIAGE_STATUS.REMEDIATING,
note: "Updated note",
});
// Then
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
expect.objectContaining({ method: "PATCH" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"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.REMEDIATING,
},
},
}),
}),
);
});
it("should update a virtual existing note through the finding UID note route", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: null,
notesCount: 1,
noteId: "note-1",
status: FINDING_TRIAGE_STATUS.REMEDIATING,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
note: "Updated note",
});
// Then
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1",
expect.objectContaining({ method: "PATCH" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
data: {
type: "finding-triages",
attributes: {
status: FINDING_TRIAGE_STATUS.REMEDIATING,
},
},
}),
}),
);
});
it("should not patch virtual triage route for virtual existing-note-only updates", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: null,
notesCount: 1,
noteId: "note-1",
note: "Updated note",
});
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1",
expect.objectContaining({ method: "PATCH" }),
);
});
it("should delete a virtual existing note when it is cleared", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: null,
notesCount: 1,
noteId: "note-1",
note: "",
});
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1",
expect.objectContaining({
method: "DELETE",
headers: { Authorization: "Bearer token" },
}),
);
});
it("should create a mute rule when status is Risk Accepted", async () => {
// Given
const { updateFindingTriage } = await importActions();
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.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
});
// Then
expect(createMuteRuleMock).toHaveBeenCalledOnce();
const formData = createMuteRuleMock.mock.calls[0][1] as FormData;
expect(formData.get("finding_ids")).toBe(
JSON.stringify(["finding-snapshot-id"]),
);
expect(formData.get("name")).toBe(
"Finding triage: Risk Accepted - finding-snapshot-id",
);
expect(formData.get("reason")).toBe(
"Finding triage status changed to Risk Accepted.",
);
});
it("should reject and skip muting when triage patch returns an action error", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({
error: "Triage failed",
status: 400,
});
// When / Then
await expect(
updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
}),
).rejects.toThrow("Triage failed");
expect(createMuteRuleMock).not.toHaveBeenCalled();
});
it("should rollback triage status when automatic muting fails", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
createMuteRuleMock.mockResolvedValue({
errors: { general: "Mute failed" },
});
// When / Then
await expect(
updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
}),
).rejects.toThrow("Mute failed");
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"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.OPEN,
},
},
}),
}),
);
});
it("should rollback virtual triage status through encoded finding UID when automatic muting fails", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
createMuteRuleMock.mockResolvedValue({
errors: { general: "Mute failed" },
});
// When / Then
await expect(
updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: null,
notesCount: 0,
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
}),
).rejects.toThrow("Mute failed");
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
data: {
type: "finding-triages",
attributes: {
status: FINDING_TRIAGE_STATUS.OPEN,
},
},
}),
}),
);
});
it("should not create a mute rule when the finding is already muted", async () => {
// Given
const { updateFindingTriage } = await importActions();
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.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
isMuted: true,
});
// Then
expect(createMuteRuleMock).not.toHaveBeenCalled();
});
it("should not create a mute rule when moving between shortcut statuses", async () => {
// Given
const { updateFindingTriage } = await importActions();
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.FALSE_POSITIVE,
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
});
// Then
expect(createMuteRuleMock).not.toHaveBeenCalled();
});
it("should not create a mute rule when shortcut status did not change", async () => {
// Given
const { updateFindingTriage } = await importActions();
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.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
note: "First note",
});
// Then
expect(createMuteRuleMock).not.toHaveBeenCalled();
});
it("should not create a mute rule for regular triage statuses", async () => {
// Given
const { updateFindingTriage } = await importActions();
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.UNDER_REVIEW,
});
// Then
expect(createMuteRuleMock).not.toHaveBeenCalled();
});
it("should update virtual triage through the finding UID route, not the snapshot id", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "finding/stable/uid",
triageId: null,
notesCount: 0,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
});
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
expect.objectContaining({ method: "PATCH" }),
);
});
it("should resolve findingUid from findingId before creating virtual triage", async () => {
// Given
const { updateFindingTriage } = await importActions();
handleApiResponseMock
.mockResolvedValueOnce({
data: { attributes: { uid: "finding/stable/uid" } },
})
.mockResolvedValueOnce({ data: { id: "triage-1" } });
// When
await updateFindingTriage({
findingId: "finding-snapshot-id",
findingUid: "",
triageId: null,
notesCount: 0,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
note: "First note",
});
// Then
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/findings/finding-snapshot-id",
expect.any(Object),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
data: {
type: "finding-triages",
attributes: {
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
note: "First note",
},
},
}),
}),
);
});
});
+264
View File
@@ -0,0 +1,264 @@
"use server";
import { 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,
type FindingTriageLoadedNote,
type FindingTriageSummary,
isMutelistShortcutStatus,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
const buildFindingTriageBody = ({
status,
note,
}: {
status?:
| UpdateFindingTriageInput["status"]
| UpdateFindingTriageInput["previousStatus"];
note?: UpdateFindingTriageInput["note"];
}) => ({
data: {
type: "finding-triages",
attributes: {
...(status ? { status } : {}),
...(note ? { note } : {}),
},
},
});
const buildFindingTriageNoteBody = (body: string) => ({
data: {
type: "finding-triage-notes",
attributes: {
body,
},
},
});
const buildApiUrl = (path: `/${string}`) => {
if (!apiBaseUrl) {
throw new Error("API base URL is not configured.");
}
const url = new URL(apiBaseUrl);
url.pathname = `${url.pathname.replace(/\/$/, "")}${path}`;
return url.toString();
};
async function getJsonApi(path: `/${string}`) {
const headers = await getAuthHeaders({ contentType: false });
const response = await fetch(buildApiUrl(path), {
headers,
});
return handleApiResponse(response);
}
const throwIfApiError = (result: unknown) => {
if (
result &&
typeof result === "object" &&
("error" in result ||
("status" in result &&
typeof result.status === "number" &&
result.status >= 400))
) {
throw new Error(
"error" in result && typeof result.error === "string"
? result.error
: "Finding triage request failed.",
);
}
};
async function patchJsonApi(path: `/${string}`, body: unknown) {
const headers = await getAuthHeaders({ contentType: false });
const response = await fetch(buildApiUrl(path), {
method: "PATCH",
headers: {
...headers,
"Content-Type": JSON_API_CONTENT_TYPE,
},
body: JSON.stringify(body),
});
const result = await handleApiResponse(response);
throwIfApiError(result);
return result;
}
async function deleteJsonApi(path: `/${string}`) {
const headers = await getAuthHeaders({ contentType: false });
const response = await fetch(buildApiUrl(path), {
method: "DELETE",
headers,
});
const result = await handleApiResponse(response);
throwIfApiError(result);
return result;
}
const shouldCreateTriageMuteRule = (
input: UpdateFindingTriageInput,
): input is UpdateFindingTriageInput & {
status: NonNullable<UpdateFindingTriageInput["status"]>;
} =>
Boolean(input.status) &&
input.previousStatus !== undefined &&
input.status !== input.previousStatus &&
input.isMuted !== true &&
isMutelistShortcutStatus(input.status) &&
!isMutelistShortcutStatus(input.previousStatus);
async function createTriageMuteRule(input: UpdateFindingTriageInput) {
if (!shouldCreateTriageMuteRule(input)) {
return;
}
const label = FINDING_TRIAGE_STATUS_LABELS[input.status];
const formData = new FormData();
formData.set("name", `Finding triage: ${label} - ${input.findingId}`);
formData.set("reason", `Finding triage status changed to ${label}.`);
formData.set("finding_ids", JSON.stringify([input.findingId]));
const result = await createMuteRule(null, formData);
if (result?.errors) {
throw new Error(
result.errors.general ||
result.errors.finding_ids ||
result.errors.name ||
result.errors.reason ||
"Could not mute finding after triage status change.",
);
}
}
const encodePathSegment = (value: string) => encodeURIComponent(value);
async function rollbackTriageStatus(
input: UpdateFindingTriageInput,
findingUid?: string,
) {
if (!input.previousStatus || !shouldCreateTriageMuteRule(input)) {
return;
}
const previousStatus = input.previousStatus;
if (input.triageId) {
await patchJsonApi(
`/finding-triages/${input.triageId}`,
buildFindingTriageBody({ status: previousStatus }),
);
return;
}
if (findingUid) {
await patchJsonApi(
`/findings/${encodePathSegment(findingUid)}/triage`,
buildFindingTriageBody({ status: previousStatus }),
);
}
}
async function createMuteRuleOrRollback(
input: UpdateFindingTriageInput,
findingUid?: string,
) {
try {
await createTriageMuteRule(input);
} catch (error) {
try {
await rollbackTriageStatus(input, findingUid);
} catch (rollbackError) {
console.error("Could not rollback finding triage status.", rollbackError);
}
throw error;
}
}
async function resolveFindingUid({
findingId,
findingUid,
}: Pick<UpdateFindingTriageInput, "findingId" | "findingUid">) {
if (findingUid) {
return findingUid;
}
const apiResponse = await getJsonApi(
`/findings/${encodePathSegment(findingId)}`,
);
const resolvedFindingUid = apiResponse?.data?.attributes?.uid;
if (typeof resolvedFindingUid !== "string" || !resolvedFindingUid) {
throw new Error("Cannot create finding triage without findingUid.");
}
return resolvedFindingUid;
}
export async function loadLatestFindingTriageNote(
triage: FindingTriageSummary,
): Promise<FindingTriageLoadedNote> {
const findingUid = triage.triageId
? triage.findingUid
: await resolveFindingUid(triage);
const apiResponse = await getJsonApi(
triage.triageId
? `/finding-triages/${triage.triageId}/notes`
: `/findings/${encodePathSegment(findingUid)}/triage/notes`,
);
const latestNote = adaptLatestFindingTriageNote(apiResponse);
if (!latestNote) {
throw new Error("Could not load the latest finding triage note.");
}
return latestNote;
}
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
let findingUid: string | undefined;
let triagePath: `/${string}`;
if (input.triageId) {
triagePath = `/finding-triages/${input.triageId}`;
} else {
findingUid = await resolveFindingUid(input);
triagePath = `/findings/${encodePathSegment(findingUid)}/triage`;
}
if (input.note !== undefined && input.notesCount > 0 && input.noteId) {
const notePath: `/${string}` = `${triagePath}/notes/${input.noteId}`;
const noteResult =
input.note === ""
? await deleteJsonApi(notePath)
: await patchJsonApi(notePath, buildFindingTriageNoteBody(input.note));
if (!input.status) {
return noteResult;
}
}
if (!input.status && !(input.note && input.notesCount === 0)) {
return undefined;
}
const result = await patchJsonApi(
triagePath,
buildFindingTriageBody({
status: input.status,
note: input.notesCount === 0 && input.note ? input.note : undefined,
}),
);
await createMuteRuleOrRollback(input, findingUid);
return result;
}
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiResponseMock,
appendSanitizedProviderTypeFiltersMock,
redirectMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
appendSanitizedProviderTypeFiltersMock: vi.fn(),
redirectMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: redirectMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/provider-filters", () => ({
appendSanitizedProviderTypeFilters: appendSanitizedProviderTypeFiltersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import { getFindings, getLatestFindings } from "./findings";
const findingsResponse = {
data: [
{
type: "findings",
id: "finding-1",
attributes: {
uid: "prowler-finding-uid-1",
status: "FAIL",
triage_status: "under_review",
triage_has_note: true,
},
},
],
meta: { pagination: { page: 1 } },
};
describe("findings actions triage projection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
handleApiResponseMock.mockResolvedValue(findingsResponse);
});
it("should attach domain triage DTOs to historical findings responses", async () => {
// When
const result = await getFindings({ page: 1, pageSize: 10 });
// Then
expect(result?.data[0].triage).toEqual(
expect.objectContaining({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
canEdit: false,
disabledReason: "cloud_only",
}),
);
expect(result?.data[0].triage).not.toHaveProperty("triage_status");
expect(result?.data[0].triage).not.toHaveProperty("attributes");
});
it("should attach domain triage DTOs to latest findings responses", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
const result = await getLatestFindings({ page: 1, pageSize: 10 });
// Then
expect(result?.data[0].triage).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
canEdit: true,
}),
);
expect(result?.data[0].triage).not.toHaveProperty("disabledReason");
});
});
+16 -2
View File
@@ -2,9 +2,21 @@
import { redirect } from "next/navigation";
import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findings-triage.adapter";
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
const withFindingTriageSummaries = <T extends { data?: unknown }>(
response: T | undefined,
): T | undefined => {
return attachFindingTriageSummariesToResponse(
response,
getFindingTriageAdapterOptions(),
);
};
export const getFindings = async ({
page = 1,
pageSize = 10,
@@ -31,8 +43,9 @@ export const getFindings = async ({
const findings = await fetch(url.toString(), {
headers,
});
const response = await handleApiResponse(findings);
return handleApiResponse(findings);
return withFindingTriageSummaries(response);
} catch (error) {
console.error("Error fetching findings:", error);
return undefined;
@@ -67,8 +80,9 @@ export const getLatestFindings = async ({
const findings = await fetch(url.toString(), {
headers,
});
const response = await handleApiResponse(findings);
return handleApiResponse(findings);
return withFindingTriageSummaries(response);
} catch (error) {
console.error("Error fetching findings:", error);
return undefined;
+1
View File
@@ -1,3 +1,4 @@
export * from "./findings";
export * from "./findings-by-resource";
export * from "./findings-by-resource.adapter";
export * from "./findings-triage";
+14 -7
View File
@@ -158,17 +158,24 @@ export const createMuteRule = async (
try {
if (responseContentType?.includes("application/json")) {
const errorData = await response.json();
const jsonApiError = (
errorData as {
errors?: Array<{
detail?: string;
title?: string;
source?: { pointer?: string };
}>;
message?: string;
}
)?.errors?.[0];
errorMessage =
(
errorData as {
errors?: Array<{ detail?: string }>;
message?: string;
}
)?.errors?.[0]?.detail ||
jsonApiError?.detail ||
jsonApiError?.title ||
(errorData as { message?: string })?.message ||
errorMessage;
} else {
await response.text();
const responseText = await response.text();
errorMessage = responseText || errorMessage;
}
} catch {
// JSON parsing failed, use default error message
+8 -23
View File
@@ -47,6 +47,14 @@ vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
vi.mock("@/actions/findings", () => ({
getLatestFindings: vi.fn(),
}));
vi.mock("@/actions/organizations/organizations", () => ({
listOrganizationsSafe: vi.fn(),
}));
vi.mock("@/lib/provider-filters", () => ({
appendSanitizedProviderTypeFilters: vi.fn(),
}));
@@ -102,29 +110,6 @@ describe("getResourceEvents", () => {
expect(calledUrl.searchParams.get("page[size]")).toBe("25");
});
it("returns parsed response on success", async () => {
// Given
const mockData = {
data: [
{
type: "resource-events",
id: "event-1",
attributes: { event_name: "CreateStack" },
},
],
};
const mockResponse = new Response("", { status: 200 });
fetchMock.mockResolvedValue(mockResponse);
handleApiResponseMock.mockResolvedValue(mockData);
// When
const result = await getResourceEvents("resource-123");
// Then
expect(result).toEqual(mockData);
expect(handleApiResponseMock).toHaveBeenCalledWith(mockResponse);
});
it("returns error object for non-ok responses without calling handleApiResponse", async () => {
// Given
const errorBody = JSON.stringify({
+2 -9
View File
@@ -1,6 +1,5 @@
"use client";
import { List, Settings } from "lucide-react";
import { ReactNode } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
@@ -15,14 +14,8 @@ export function MutelistTabs({ simpleContent }: MutelistTabsProps) {
return (
<Tabs defaultValue="simple" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="simple" className="gap-2">
<List className="size-4" />
Simple
</TabsTrigger>
<TabsTrigger value="advanced" className="gap-2">
<Settings className="size-4" />
Advanced
</TabsTrigger>
<TabsTrigger value="simple">Simple</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="simple">{simpleContent}</TabsContent>
@@ -60,11 +60,7 @@ export const ScanSelector = ({
</SelectTrigger>
<SelectContent>
{scans.map((scan) => (
<SelectItem
key={scan.id}
value={scan.id}
className="data-[state=checked]:bg-bg-neutral-tertiary [&_svg:not([class*='size-'])]:size-6"
>
<SelectItem key={scan.id} value={scan.id}>
<ComplianceScanInfo scan={scan} />
</SelectItem>
))}
@@ -19,6 +19,9 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children, ...props }: { children: ReactNode }) => (
<button {...props}>{children}</button>
),
Checkbox: ({
"aria-label": ariaLabel,
onCheckedChange,
@@ -35,6 +38,9 @@ vi.mock("@/components/shadcn", () => ({
{...props}
/>
),
Textarea: (props: InputHTMLAttributes<HTMLTextAreaElement>) => (
<textarea {...props} />
),
}));
vi.mock("@/components/ui/table", () => ({
@@ -78,6 +84,44 @@ vi.mock("./notification-indicator", () => ({
},
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
open,
title,
}: {
children: ReactNode;
open: boolean;
title?: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({
children,
disabled,
"aria-label": ariaLabel,
}: {
children: ReactNode;
disabled?: boolean;
"aria-label"?: string;
}) => (
<button aria-label={ariaLabel} disabled={disabled}>
{children}
</button>
),
}));
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
@@ -248,7 +292,7 @@ function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
// ---------------------------------------------------------------------------
describe("column-finding-groups — accessibility of check title cell", () => {
it("should not expose an impacted providers column", () => {
it("should not expose triage and notes columns on group-level rows", () => {
// Given
const columns = getColumnFindingGroups({
rowSelection: {},
@@ -257,12 +301,16 @@ describe("column-finding-groups — accessibility of check title cell", () => {
});
// When
const impactedProvidersColumn = columns.find(
(col) => (col as { id?: string }).id === "impactedProviders",
const columnIds = columns.map(
(column) =>
(column as { id?: string; accessorKey?: string }).id ??
(column as { id?: string; accessorKey?: string }).accessorKey,
);
// Then
expect(impactedProvidersColumn).toBeUndefined();
expect(columnIds).not.toContain("triage");
expect(columnIds).not.toContain("notes");
expect(columnIds.at(-1)).toBe("actions");
});
it("should render the first provider icon with its provider name", () => {
@@ -290,22 +338,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
expect(button.tagName.toLowerCase()).toBe("button");
});
it("should NOT render the check title as a <p> element", () => {
// Given
const onDrillDown =
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
// When
renderFindingCell("S3 Bucket Public Access", onDrillDown);
// Then — <p> should not exist as the interactive element
const paragraphs = document.querySelectorAll("p");
const clickableParagraph = Array.from(paragraphs).find(
(p) => p.textContent === "S3 Bucket Public Access",
);
expect(clickableParagraph).toBeUndefined();
});
it("should call onDrillDown when the button is clicked", async () => {
// Given
const onDrillDown =
@@ -328,23 +360,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
);
});
it("should call onDrillDown when Enter key is pressed on the button", async () => {
// Given
const onDrillDown =
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
const user = userEvent.setup();
renderFindingCell("My Check Title", onDrillDown);
// When — tab to button and press Enter
const button = screen.getByRole("button", { name: "My Check Title" });
button.focus();
await user.keyboard("{Enter}");
// Then — native button handles Enter natively
expect(onDrillDown).toHaveBeenCalledTimes(1);
});
it("should allow expanding a group that only has PASS resources", async () => {
// Given
const user = userEvent.setup();
@@ -396,26 +411,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
expect(screen.getByText("Fallback IaC Check")).toBeInTheDocument();
expect(onDrillDown).not.toHaveBeenCalled();
});
it("should keep fallback groups non-clickable when the displayed total is zero", () => {
// Given
const onDrillDown =
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
// When
renderFindingCell("No failing findings", onDrillDown, {
resourcesTotal: 0,
resourcesFail: 0,
failCount: 0,
passCount: 0,
});
// Then
expect(
screen.queryByRole("button", { name: "No failing findings" }),
).not.toBeInTheDocument();
expect(screen.getByText("No failing findings")).toBeInTheDocument();
});
});
describe("column-finding-groups — impacted resources count", () => {
@@ -490,23 +485,6 @@ describe("column-finding-groups — group selection", () => {
).not.toBeInTheDocument();
expect(onDrillDown).not.toHaveBeenCalled();
});
it("should hide the chevron for zero-resource groups when the displayed total is zero", () => {
// Given/When
renderSelectCell({
resourcesTotal: 0,
resourcesFail: 0,
failCount: 0,
passCount: 0,
});
// Then
expect(
screen.queryByRole("button", {
name: "Expand S3 Bucket Public Access",
}),
).not.toBeInTheDocument();
});
});
describe("column-finding-groups — indicators", () => {
@@ -1,9 +1,16 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { InputHTMLAttributes, ReactNode } from "react";
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/shadcn", () => ({
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
Checkbox: ({
"aria-label": ariaLabel,
onCheckedChange,
@@ -66,15 +73,65 @@ vi.mock("@/components/shadcn/dropdown", () => ({
}));
vi.mock("@/components/shadcn/info-field/info-field", () => ({
InfoField: () => null,
InfoField: ({
children,
label,
}: {
children: ReactNode;
label: string;
variant?: string;
}) => (
<div>
<span>{label}</span>
<div>{children}</div>
</div>
),
}));
vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => null,
}));
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({
children,
disabled,
"aria-label": ariaLabel,
}: {
children: ReactNode;
disabled?: boolean;
"aria-label"?: string;
}) => (
<button aria-label={ariaLabel} disabled={disabled}>
{children}
</button>
),
SelectValue: ({ children }: { children?: ReactNode }) => (
<span>{children}</span>
),
}));
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span>{children}</span>
),
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: () => null,
DateWithTime: ({
dateTime,
inline,
}: {
dateTime: string | null;
inline?: boolean;
}) => <time data-inline={inline ? "true" : "false"}>{dateTime ?? "-"}</time>,
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
@@ -120,8 +177,35 @@ vi.mock("./notification-indicator", () => ({
}));
import type { FindingResourceRow } from "@/types";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import { getColumnFindingResources } from "./column-finding-resources";
import {
CLOUD_ONLY_TOOLTIP_COPY,
EDITING_UNAVAILABLE_COPY,
} from "./finding-triage-cells";
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "prowler-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",
...overrides,
};
}
function makeResource(
overrides?: Partial<FindingResourceRow>,
@@ -150,78 +234,203 @@ function makeResource(
};
}
describe("column-finding-resources", () => {
it("should pass delta to NotificationIndicator for resource rows", () => {
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
function getColumnIds(columns: ReturnType<typeof getColumnFindingResources>) {
return columns.map(
(column) =>
(column as { id?: string; accessorKey?: string }).id ??
(column as { id?: string; accessorKey?: string }).accessorKey,
);
}
const selectColumn = columns.find(
(col) => (col as { id?: string }).id === "select",
);
if (!selectColumn?.cell) {
throw new Error("select column not found");
}
const CellComponent = selectColumn.cell as (props: {
row: {
id: string;
original: FindingResourceRow;
toggleSelected: (selected: boolean) => void;
};
}) => ReactNode;
render(
<div>
{CellComponent({
row: {
id: "0",
original: makeResource(),
toggleSelected: vi.fn(),
},
})}
</div>,
);
expect(screen.getByLabelText("Select resource")).toBeInTheDocument();
expect(notificationIndicatorMock).toHaveBeenCalledWith(
expect.objectContaining({
delta: "new",
isMuted: false,
}),
);
function renderResourceActionsCell({
resource = makeResource(),
onTriageUpdateAction,
onTriageNoteLoadAction,
}: {
resource?: FindingResourceRow;
onTriageUpdateAction?: Parameters<
typeof getColumnFindingResources
>[0]["onTriageUpdateAction"];
onTriageNoteLoadAction?: Parameters<
typeof getColumnFindingResources
>[0]["onTriageNoteLoadAction"];
} = {}) {
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
onTriageUpdateAction,
onTriageNoteLoadAction,
});
it("should render the resource EntityInfo with resourceName as alias", () => {
const actionsColumn = columns.find(
(col) => (col as { id?: string }).id === "actions",
);
if (!actionsColumn?.cell) {
throw new Error("actions column not found");
}
const CellComponent = actionsColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
render(<div>{CellComponent({ row: { original: resource } })}</div>);
}
describe("column-finding-resources", () => {
it("should render actions as the last visible column after Triage without Notes", () => {
// Given
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const resourceColumn = columns.find(
(col) => (col as { id?: string }).id === "resource",
);
if (!resourceColumn?.cell) {
throw new Error("resource column not found");
}
// When
const columnIds = getColumnIds(columns);
const CellComponent = resourceColumn.cell as (props: {
// Then
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
expect(columnIds).not.toContain("notes");
expect(
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
).toBe(56);
});
it("should render Open note in resource actions without exposing note preview metadata", () => {
// Given
renderResourceActionsCell({
resource: makeResource({
triage: makeTriageSummary({ hasVisibleNote: true }),
}),
onTriageUpdateAction: vi.fn(),
onTriageNoteLoadAction: vi.fn(),
});
// Then
expect(screen.getByRole("button", { name: "Open note" })).toBeEnabled();
expect(screen.queryByText("Sensitive note body")).not.toBeInTheDocument();
expect(screen.queryByText(/author/i)).not.toBeInTheDocument();
expect(screen.queryByText(/timestamp/i)).not.toBeInTheDocument();
});
it("should disable Add Triage Note when no update handler is wired", () => {
// Given
renderResourceActionsCell({
resource: makeResource({
triage: makeTriageSummary({ hasVisibleNote: false }),
}),
});
// Then
expect(
screen.getByRole("button", { name: "Add Triage Note" }),
).toBeDisabled();
});
it("should enable Add Triage Note when an update handler is wired", () => {
// Given
renderResourceActionsCell({
resource: makeResource({
triage: makeTriageSummary({ hasVisibleNote: false }),
}),
onTriageUpdateAction: vi.fn(),
});
// Then
expect(
screen.getByRole("button", { name: "Add Triage Note" }),
).toBeEnabled();
});
it("should enable Add Triage Note for Cloud-only rows so users can open the billing upsell modal", () => {
// Given
renderResourceActionsCell({
resource: makeResource({
triage: makeTriageSummary({
canEdit: false,
hasVisibleNote: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
}),
}),
});
// Then
expect(
screen.getByRole("button", { name: "Add Triage Note" }),
).toBeEnabled();
});
it("should disable editable triage control when no update handler is wired", () => {
// Given
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const triageColumn = columns.find(
(col) => (col as { id?: string }).id === "triage",
);
if (!triageColumn?.cell) {
throw new Error("triage column not found");
}
const CellComponent = triageColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
// When
render(
<div>
{CellComponent({
row: {
original: makeResource(),
original: makeResource({
triage: makeTriageSummary({ canEdit: true }),
}),
},
})}
</div>,
);
expect(screen.getByText("my-bucket")).toBeInTheDocument();
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
// Then
expect(
screen.getByRole("button", { name: "Triage status" }),
).toBeDisabled();
expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument();
});
it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => {
// Given
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const triageColumn = columns.find(
(col) => (col as { id?: string }).id === "triage",
);
if (!triageColumn?.cell) {
throw new Error("triage column not found");
}
const CellComponent = triageColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
// When
render(
<div>
{CellComponent({
row: {
original: makeResource({
triage: makeTriageSummary({
canEdit: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
}),
}),
},
})}
</div>,
);
// Then
expect(
screen.getByRole("button", { name: "Triage status" }),
).toBeDisabled();
expect(screen.getByText(CLOUD_ONLY_TOOLTIP_COPY)).toBeInTheDocument();
});
it("should open Send to Jira modal with finding UUID directly", async () => {
@@ -24,15 +24,36 @@ import {
} from "@/components/ui/table/status-finding-badge";
import { getFailingForLabel } from "@/lib/date-utils";
import { FindingResourceRow } from "@/types";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
import { canMuteFindingResource } from "./finding-resource-selection";
import {
FindingNoteActionItem,
FindingTriageStatusCell,
} from "./finding-triage-cells";
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
type DeltaType,
NotificationIndicator,
} from "./notification-indicator";
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const ResourceRowActions = ({
row,
findingTitle,
onTriageUpdateAction,
onTriageNoteLoadAction,
}: {
row: Row<FindingResourceRow>;
findingTitle?: string;
onTriageUpdateAction?: FindingTriageUpdateHandler;
onTriageNoteLoadAction?: (
triage: FindingTriageSummary,
) => Promise<FindingTriageLoadedNote>;
}) => {
const resource = row.original;
const canMute = canMuteFindingResource(resource);
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
@@ -113,6 +134,17 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
onClick={(e) => e.stopPropagation()}
>
<ActionDropdown ariaLabel="Resource actions">
<FindingNoteActionItem
triage={resource.triage}
findingContext={{
title: findingTitle || resource.checkId,
resource: resource.resourceName,
provider: resource.providerAlias,
providerType: resource.providerType,
}}
onTriageUpdateAction={onTriageUpdateAction}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>
<ActionDropdownItem
icon={
resource.isMuted ? (
@@ -141,11 +173,19 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
interface GetColumnFindingResourcesOptions {
rowSelection: RowSelectionState;
selectableRowCount: number;
findingTitle?: string;
onTriageUpdateAction?: FindingTriageUpdateHandler;
onTriageNoteLoadAction?: (
triage: FindingTriageSummary,
) => Promise<FindingTriageLoadedNote>;
}
export function getColumnFindingResources({
rowSelection,
selectableRowCount,
findingTitle,
onTriageUpdateAction,
onTriageNoteLoadAction,
}: GetColumnFindingResourcesOptions): ColumnDef<FindingResourceRow>[] {
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
const isAllSelected =
@@ -278,7 +318,9 @@ export function getColumnFindingResources({
),
cell: ({ row }) => (
<InfoField label="Region" variant="compact">
{row.original.region || "-"}
<span className="block truncate whitespace-nowrap">
{row.original.region || "-"}
</span>
</InfoField>
),
enableSorting: false,
@@ -291,7 +333,7 @@ export function getColumnFindingResources({
),
cell: ({ row }) => (
<InfoField label="Last seen" variant="compact">
<DateWithTime dateTime={row.original.lastSeenAt} inline />
<DateWithTime dateTime={row.original.lastSeenAt} />
</InfoField>
),
enableSorting: false,
@@ -312,11 +354,33 @@ export function getColumnFindingResources({
},
enableSorting: false,
},
// Actions column — mute only
// Triage
{
id: "triage",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Triage" />
),
cell: ({ row }) => (
<FindingTriageStatusCell
triage={row.original.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
),
enableSorting: false,
},
// Actions column — utility actions are kept last.
{
id: "actions",
size: 56,
header: () => <div className="w-10" />,
cell: ({ row }) => <ResourceRowActions row={row} />,
cell: ({ row }) => (
<ResourceRowActions
row={row}
findingTitle={findingTitle}
onTriageUpdateAction={onTriageUpdateAction}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>
),
enableSorting: false,
},
];
@@ -0,0 +1,125 @@
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: ({
label,
onSelect,
disabled,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button disabled={disabled} onClick={onSelect}>
{label}
</button>
),
}));
vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => null,
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: ({ dateTime }: { dateTime: string | null }) => (
<time>{dateTime ?? "-"}</time>
),
EntityInfo: () => null,
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
SeverityBadge: ({ severity }: { severity: string }) => (
<span>{severity}</span>
),
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({
children,
disabled,
"aria-label": ariaLabel,
}: {
children: ReactNode;
disabled?: boolean;
"aria-label"?: string;
}) => (
<button aria-label={ariaLabel} disabled={disabled}>
{children}
</button>
),
}));
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span>{children}</span>
),
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/lib/region-flags", () => ({
getRegionFlag: () => "",
}));
vi.mock("./finding-detail-drawer", () => ({
FindingDetailDrawer: ({ trigger }: { trigger: ReactNode }) => <>{trigger}</>,
}));
vi.mock("./notification-indicator", () => ({
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
NotificationIndicator: () => null,
}));
vi.mock("./provider-icon-cell", () => ({
ProviderIconCell: () => null,
}));
import { getStandaloneFindingColumns } from "./column-standalone-findings";
describe("column-standalone-findings", () => {
it("should render Triage and Actions as the last visible data columns without Notes", () => {
// Given
const columns = getStandaloneFindingColumns({ includeUpdatedAt: true });
// When
const columnIds = columns.map(
(column) =>
(column as { id?: string; accessorKey?: string }).id ??
(column as { id?: string; accessorKey?: string }).accessorKey,
);
// Then
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
expect(columnIds).not.toContain("notes");
expect(
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
).toBe(56);
});
});
@@ -10,15 +10,27 @@ import {
StatusFindingBadge,
} from "@/components/ui/table";
import { getRegionFlag } from "@/lib/region-flags";
import { getOptionalText } from "@/lib/utils";
import { FindingProps, ProviderType } from "@/types";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} 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";
interface GetStandaloneFindingColumnsOptions {
includeUpdatedAt?: boolean;
openFindingId?: string | null;
onTriageUpdateAction?: FindingTriageUpdateHandler;
onTriageNoteLoadAction?: (
triage: FindingTriageSummary,
) => Promise<FindingTriageLoadedNote>;
}
const getFindingsData = (row: { original: FindingProps }) => {
@@ -68,6 +80,8 @@ function FindingTitleCell({
export function getStandaloneFindingColumns({
includeUpdatedAt = false,
openFindingId = null,
onTriageUpdateAction,
onTriageNoteLoadAction,
}: GetStandaloneFindingColumnsOptions = {}): ColumnDef<FindingProps>[] {
const columns: ColumnDef<FindingProps>[] = [
{
@@ -129,14 +143,8 @@ export function getStandaloneFindingColumns({
cell: ({ row }) => {
const name = getResourceData(row, "name");
const uid = getResourceData(row, "uid");
const entityAlias =
typeof name === "string" && name.trim().length > 0 && name !== "-"
? name
: undefined;
const entityId =
typeof uid === "string" && uid.trim().length > 0 && uid !== "-"
? uid
: undefined;
const entityAlias = getOptionalText(name);
const entityId = getOptionalText(uid);
return (
<div className="max-w-[240px]">
@@ -209,7 +217,7 @@ export function getStandaloneFindingColumns({
const regionFlag =
typeof region === "string" ? getRegionFlag(region) : "";
return (
<span className="text-text-neutral-primary flex max-w-[140px] items-center gap-1.5 truncate text-sm">
<span className="text-text-neutral-primary flex max-w-[140px] min-w-0 items-center gap-1.5 truncate text-sm whitespace-nowrap">
{regionFlag && (
<span className="translate-y-px text-base leading-none">
{regionFlag}
@@ -242,5 +250,48 @@ export function getStandaloneFindingColumns({
});
}
columns.push(
{
id: "triage",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Triage" />
),
cell: ({ row }) => (
<FindingTriageStatusCell
triage={row.original.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
),
enableSorting: false,
},
{
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}
/>
);
},
enableSorting: false,
},
);
return columns;
}
@@ -32,7 +32,7 @@ vi.mock("@/components/shadcn/dropdown", () => ({
disabled,
}: {
label: string;
onSelect: () => void;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button onClick={onSelect} disabled={disabled}>
@@ -45,7 +45,45 @@ vi.mock("@/components/shadcn/spinner/spinner", () => ({
Spinner: () => <span>Loading</span>,
}));
import { DataTableRowActions } from "./data-table-row-actions";
vi.mock("./finding-note-modal", () => ({
FindingNoteModal: ({
open,
triage,
}: {
open: boolean;
triage: {
noteBody: string;
canEdit: boolean;
disabledReason?: string;
billingHref: string;
};
}) =>
open ? (
<div role="dialog" aria-label="Note">
<textarea
aria-label="Note text"
value={triage.noteBody}
disabled={!triage.canEdit}
readOnly
/>
{triage.disabledReason === "cloud_only" && (
<a href={triage.billingHref}>Available in Prowler Cloud</a>
)}
<button disabled={!triage.canEdit}>Save changes</button>
</div>
) : null,
}));
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
DataTableRowActions,
type FindingRowData,
} from "./data-table-row-actions";
import { FindingsSelectionContext } from "./findings-selection-context";
function deferredPromise<T>() {
@@ -59,6 +97,40 @@ function deferredPromise<T>() {
return { promise, resolve, reject };
}
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "prowler-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",
...overrides,
};
}
function makeFindingRow(overrides?: Partial<FindingRowData>) {
return {
original: {
id: "finding-1",
attributes: {
muted: false,
check_metadata: {
checktitle: "S3 public access",
},
},
triage: makeTriageSummary(),
...overrides,
},
} as never;
}
describe("DataTableRowActions", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -179,4 +251,76 @@ describe("DataTableRowActions", () => {
screen.getByRole("button", { name: "Mute Finding Group" }),
).toBeDisabled();
});
it("shows Add Triage Note for editable findings without a note", () => {
// Given / When
render(
<DataTableRowActions
row={makeFindingRow()}
onTriageUpdateAction={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Add Triage Note" }),
).toBeEnabled();
});
it("loads an existing note before opening the note modal", async () => {
// Given
const user = userEvent.setup();
const onTriageNoteLoadAction = vi.fn().mockResolvedValue({
noteId: "note-1",
noteBody: "Loaded existing note",
});
render(
<DataTableRowActions
row={makeFindingRow({
triage: makeTriageSummary({ hasVisibleNote: true, notesCount: 1 }),
})}
onTriageUpdateAction={vi.fn()}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Open note" }));
// Then
expect(onTriageNoteLoadAction).toHaveBeenCalledWith(
expect.objectContaining({ triageId: "triage-1", notesCount: 1 }),
);
expect(await screen.findByRole("dialog", { name: "Note" })).toBeVisible();
expect(screen.getByLabelText("Note text")).toHaveValue(
"Loaded existing note",
);
});
it("opens a disabled Cloud-only note modal from finding actions", async () => {
// Given
const user = userEvent.setup();
render(
<DataTableRowActions
row={makeFindingRow({
triage: makeTriageSummary({
canEdit: false,
hasVisibleNote: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
}),
})}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
// Then
expect(screen.getByRole("dialog", { name: "Note" })).toBeVisible();
expect(screen.getByLabelText("Note text")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
});
});
@@ -14,8 +14,17 @@ import {
} from "@/components/shadcn/dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { isFindingGroupMuted } from "@/lib/findings-groups";
import { getOptionalText } from "@/lib/utils";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
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";
export interface FindingRowData {
@@ -26,6 +35,20 @@ export interface FindingRowData {
checktitle?: string;
};
};
triage?: FindingTriageSummary;
relationships?: {
resource?: {
attributes?: {
name?: string;
};
};
provider?: {
attributes?: {
alias?: string;
provider?: string;
};
};
};
// Flat shape for FindingGroupRow
rowType?: string;
checkId?: string;
@@ -70,11 +93,19 @@ function extractRowInfo(data: FindingRowData) {
interface DataTableRowActionsProps<T extends FindingRowData> {
row: Row<T>;
onMuteComplete?: (findingIds: string[]) => void;
findingContext?: FindingTriageContext;
onTriageUpdateAction?: FindingTriageUpdateHandler;
onTriageNoteLoadAction?: (
triage: FindingTriageSummary,
) => Promise<FindingTriageLoadedNote>;
}
export function DataTableRowActions<T extends FindingRowData>({
row,
onMuteComplete,
findingContext,
onTriageUpdateAction,
onTriageNoteLoadAction,
}: DataTableRowActionsProps<T>) {
const router = useRouter();
const finding = row.original;
@@ -86,6 +117,18 @@ export function DataTableRowActions<T extends FindingRowData>({
>(null);
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
const resolvedFindingContext = findingContext ?? {
title: findingTitle,
resource: getOptionalText(
finding.relationships?.resource?.attributes?.name,
),
provider: getOptionalText(
finding.relationships?.provider?.attributes?.alias,
),
providerType: getOptionalText(
finding.relationships?.provider?.attributes?.provider,
) as ProviderType | undefined,
};
// Get selection context - if there are other selected rows, include them
const selectionContext = useContext(FindingsSelectionContext);
@@ -204,8 +247,19 @@ export function DataTableRowActions<T extends FindingRowData>({
preparationError={mutePreparationError}
/>
<div className="flex items-center justify-end">
<div
className="flex items-center justify-end"
onClick={(event) => event.stopPropagation()}
>
<ActionDropdown ariaLabel="Finding actions">
{!isGroup && (
<FindingNoteActionItem
triage={finding.triage}
findingContext={resolvedFindingContext}
onTriageUpdateAction={onTriageUpdateAction}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>
)}
<ActionDropdownItem
icon={
isMuted ? (
@@ -68,6 +68,7 @@ export function FindingDetailDrawer({
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleMuteComplete}
onTriageUpdate={drawer.patchTriageUpdate}
/>
);
}
@@ -93,6 +94,7 @@ export function FindingDetailDrawer({
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleMuteComplete}
onTriageUpdate={drawer.patchTriageUpdate}
/>
</>
);
@@ -0,0 +1,330 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
ProviderTypeIcon: ({ type }: { type: string }) => (
<span data-testid={`${type}-provider-badge`}>{type} icon</span>
),
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
open,
title,
}: {
children: ReactNode;
open: boolean;
title?: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
<h2>{title}</h2>
{children}
</div>
) : null,
}));
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
import {
FindingNoteModal,
type FindingTriageContext,
} from "./finding-note-modal";
function makeTriageDetail(
overrides?: Partial<FindingTriageDetail>,
): FindingTriageDetail {
return {
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: "triage-1",
notesCount: 1,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
isMuted: false,
canEdit: true,
billingHref: "https://prowler.com/pricing",
noteId: "note-1",
noteBody: "Existing investigation note",
maxNoteLength: 500,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
...overrides,
};
}
function renderNoteModal({
triage = makeTriageDetail(),
onTriageUpdateAction = vi.fn(),
onOpenChange = vi.fn(),
findingContext = {
title: "S3 bucket allows public reads",
resource: "production-bucket",
provider: "production-account",
},
}: {
triage?: FindingTriageDetail;
onTriageUpdateAction?: (input: UpdateFindingTriageInput) => void;
onOpenChange?: (open: boolean) => void;
findingContext?: FindingTriageContext;
} = {}) {
render(
<FindingNoteModal
open
onOpenChange={onOpenChange}
triage={triage}
findingContext={findingContext}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
return { onTriageUpdateAction, onOpenChange };
}
describe("FindingNoteModal", () => {
it("should render the provider badge from the row provider type", () => {
// Given / When
renderNoteModal({
findingContext: {
title: "Azure finding",
provider: "azure-subscription",
providerType: "azure",
},
});
// Then
expect(screen.getByTestId("azure-provider-badge")).toBeVisible();
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
});
it("should open with title Add Triage Note and current status preselected", () => {
// Given / When
renderNoteModal({
triage: makeTriageDetail({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
}),
});
// 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).getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Remediating");
expect(
within(dialog).getByText(/automatically changed to Resolved/i),
).toBeVisible();
});
it("should send existing note changes with noteId and without duplicate-note status payload", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
renderNoteModal({ onTriageUpdateAction });
// When
const textarea = screen.getByLabelText("Note text");
await user.clear(textarea);
await user.type(textarea, "Documented owner follow-up.");
await user.click(screen.getByRole("button", { name: "Save changes" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: "triage-1",
notesCount: 1,
noteId: "note-1",
isMuted: false,
note: "Documented owner follow-up.",
});
});
it("should send status plus note only when creating the first note", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
renderNoteModal({
triage: makeTriageDetail({
triageId: null,
notesCount: 0,
noteId: null,
noteBody: "",
hasVisibleNote: false,
}),
onTriageUpdateAction,
});
// When
const textarea = screen.getByLabelText("Note text");
await user.type(textarea, " Initial triage note. ");
await user.click(screen.getByRole("button", { name: "Save changes" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: null,
notesCount: 0,
noteId: null,
isMuted: false,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
note: "Initial triage note.",
});
});
it("should send an empty body when an existing note is cleared", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
const onTriageUpdateAction = vi.fn();
renderNoteModal({ onOpenChange, onTriageUpdateAction });
// When
await user.clear(screen.getByLabelText("Note text"));
await user.click(screen.getByRole("button", { name: "Save changes" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: "triage-1",
notesCount: 1,
noteId: "note-1",
isMuted: false,
note: "",
});
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("should keep the modal open and show an error when note update fails", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
renderNoteModal({ onOpenChange, onTriageUpdateAction });
// When
await user.clear(screen.getByLabelText("Note text"));
await user.type(screen.getByLabelText("Note text"), "Changed note");
await user.click(screen.getByRole("button", { name: "Save changes" }));
// Then
expect(
await screen.findByText("Could not update the note. Please try again."),
).toBeVisible();
expect(
screen.getByRole("dialog", { name: "Add Triage Note" }),
).toBeInTheDocument();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("should render counter, privacy copy, and cancel/update actions", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
renderNoteModal({ onOpenChange });
// When
await user.clear(screen.getByLabelText("Note text"));
await user.type(screen.getByLabelText("Note text"), "abc");
// Then
expect(screen.getByText("3/500")).toBeInTheDocument();
expect(screen.getByText(FINDING_TRIAGE_NOTE_PRIVACY_COPY)).toBeVisible();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(
screen.getByRole("button", { name: "Save changes" }),
).toBeInTheDocument();
});
it("should disable controls and show the Cloud upsell badge for non-paying users", () => {
// Given
renderNoteModal({
triage: makeTriageDetail({
canEdit: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
}),
});
// Then
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveAttribute("data-disabled", "");
expect(screen.getByLabelText("Note text")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
expect(screen.queryByText(/will be muted/i)).not.toBeInTheDocument();
});
it("should show modal-origin Mutelist info and still save accepted-risk statuses", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
renderNoteModal({
triage: makeTriageDetail({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
noteBody: "",
}),
onTriageUpdateAction,
});
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
// Then
expect(screen.getByText(/will be muted/i)).toBeVisible();
await waitFor(() =>
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(),
);
// When
await user.click(screen.getByRole("button", { name: "Save changes" }));
// Then
await waitFor(() =>
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.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
}),
);
});
});
@@ -0,0 +1,228 @@
"use client";
import { type FormEvent, useRef, useState } from "react";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { Alert, AlertDescription, Button, Textarea } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
type FindingTriageStatus,
isMutelistShortcutStatus,
} from "@/types/findings-triage";
import type { ProviderType } from "@/types/providers";
import {
FindingTriageStatusControl,
type FindingTriageUpdateHandler,
} 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;
onTriageUpdateAction?: FindingTriageUpdateHandler;
}
const MUTELIST_INFO_COPY =
"This finding will be muted through the existing Mutelist flow.";
const REMEDIATING_INFO_COPY =
"Once this finding is fixed and passes in the next scan, it will be automatically changed to Resolved.";
export function FindingNoteModal({
open,
onOpenChange,
triage,
findingContext,
onTriageUpdateAction,
}: FindingNoteModalProps) {
// Local state needed: modal edits are buffered until the user chooses Update.
const [selectedStatus, setSelectedStatus] = useState<FindingTriageStatus>(
triage.status,
);
const [note, setNote] = useState(triage.noteBody);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const noteTextareaRef = useRef<HTMLTextAreaElement>(null);
const canSubmit =
triage.canEdit && Boolean(onTriageUpdateAction) && !isSubmitting;
const isCloudOnly =
triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY;
const shouldShowMutelistInfo =
canSubmit &&
!triage.isMuted &&
selectedStatus !== triage.status &&
isMutelistShortcutStatus(selectedStatus);
const shouldShowRemediatingInfo =
selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING;
// 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) => {
const textarea = noteTextareaRef.current;
if (textarea && !textarea.disabled) {
event.preventDefault();
textarea.focus();
}
// Otherwise let Radix auto-focus the first control inside the dialog.
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSubmit) {
return;
}
setSubmitError(null);
setIsSubmitting(true);
try {
const updateInput = buildFindingTriageUpdateInput({
triage,
selectedStatus,
noteBody: note,
});
if (!updateInput) {
onOpenChange(false);
return;
}
await onTriageUpdateAction?.(updateInput);
onOpenChange(false);
} catch {
setSubmitError("Could not update the note. Please try again.");
} finally {
setIsSubmitting(false);
}
};
return (
<Modal
open={open}
onOpenChange={onOpenChange}
onOpenAutoFocus={handleOpenAutoFocus}
title="Add Triage Note"
size="lg"
>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex items-center gap-4">
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-lg">
{findingContext.providerType ? (
<ProviderTypeIcon type={findingContext.providerType} size={22} />
) : (
<span className="text-text-neutral-secondary text-xs font-semibold">
{findingContext.provider?.slice(0, 3).toUpperCase() ?? "—"}
</span>
)}
</div>
<div>
<p className="text-text-neutral-primary text-sm font-semibold">
{findingContext.title}
</p>
{(findingContext.resource || findingContext.provider) && (
<p className="text-text-neutral-secondary mt-1 text-xs">
{[findingContext.resource, findingContext.provider]
.filter(Boolean)
.join(" · ")}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-text-neutral-primary text-sm font-semibold">
Status:
</span>
<FindingTriageStatusControl
origin={FINDING_TRIAGE_ORIGIN.MODAL}
triage={triage}
value={selectedStatus}
onValueChange={setSelectedStatus}
/>
</div>
{shouldShowMutelistInfo && (
<Alert variant="warning">
<AlertDescription>{MUTELIST_INFO_COPY}</AlertDescription>
</Alert>
)}
{shouldShowRemediatingInfo && (
<Alert variant="info">
<AlertDescription>{REMEDIATING_INFO_COPY}</AlertDescription>
</Alert>
)}
{submitError && (
<Alert variant="error">
<AlertDescription>{submitError}</AlertDescription>
</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-between gap-3">
<p className="text-text-neutral-tertiary text-xs">
{triage.privacyCopy}
</p>
<p className="text-text-neutral-tertiary shrink-0 text-xs">
{note.length}/{triage.maxNoteLength}
</p>
</div>
</div>
<div className="flex w-full justify-end gap-3">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<span className="relative inline-flex">
{isCloudOnly && (
<span className="absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
<CloudFeatureBadgeLink href={triage.billingHref} />
</span>
)}
<Button
type={canSubmit ? "submit" : "button"}
size="lg"
disabled={!canSubmit}
>
{isSubmitting
? "Saving..."
: canSubmit || isCloudOnly
? "Save changes"
: "Unavailable"}
</Button>
</span>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,671 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
open,
title,
}: {
children: ReactNode;
open: boolean;
title?: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
<h2>{title}</h2>
{children}
</div>
) : null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdownItem: ({
label,
onSelect,
disabled,
}: {
label: ReactNode;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button disabled={disabled} onClick={onSelect}>
{label}
</button>
),
}));
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
FindingNoteActionItem,
FindingTriageStatusCell,
} from "./finding-triage-cells";
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "prowler-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",
...overrides,
};
}
describe("finding triage cells", () => {
it("should open the Note modal from the note action with the current status preselected", async () => {
// Given
const user = userEvent.setup();
render(
<FindingNoteActionItem
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
})}
findingContext={{ title: "S3 bucket allows public reads" }}
onTriageUpdateAction={vi.fn()}
/>,
);
// When
const addNoteButton = screen.getByRole("button", {
name: "Add Triage Note",
});
expect(addNoteButton).toHaveTextContent("Add Triage Note");
await user.click(addNoteButton);
// Then
expect(
screen.getByRole("dialog", { name: "Add Triage Note" }),
).toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Remediating");
});
it("should not propagate table status clicks to the row", async () => {
// Given
const user = userEvent.setup();
const onRowClick = vi.fn();
render(
<div onClick={onRowClick}>
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={vi.fn()}
/>
</div>,
);
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
// Then
expect(onRowClick).not.toHaveBeenCalled();
});
it("should render status picker with fixed width and colored options", async () => {
// Given
const user = userEvent.setup();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
})}
onTriageUpdateAction={vi.fn()}
/>,
);
const statusControl = screen.getByRole("combobox", {
name: "Triage status",
});
// When
await user.click(statusControl);
// Then
expect(screen.getByText("Triage").parentElement).toHaveClass(
"text-text-neutral-secondary",
"text-[10px]",
"whitespace-nowrap",
);
expect(statusControl.parentElement).toHaveClass("w-32");
expect(statusControl).toHaveAttribute("data-size", "xs");
expect(within(statusControl).getByText("Under Review")).toHaveClass(
"text-text-warning-primary",
);
expect(
within(screen.getByRole("option", { name: "Open" })).getByText("Open"),
).toHaveClass("text-text-error-primary");
expect(
within(screen.getByRole("option", { name: "Under Review" })).getByText(
"Under Review",
),
).toHaveClass("text-text-warning-primary");
expect(
within(screen.getByRole("option", { name: "Remediating" })).getByText(
"Remediating",
),
).toHaveClass("text-bg-data-info");
expect(
within(screen.getByRole("option", { name: "Risk Accepted" })).getByText(
"Risk Accepted",
),
).toHaveClass("text-bg-pass");
expect(
within(screen.getByRole("option", { name: "False Positive" })).getByText(
"False Positive",
),
).toHaveClass("text-text-neutral-secondary");
});
it("should disable table status mutation when no update handler is wired", async () => {
// Given
const user = userEvent.setup();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
canEdit: true,
})}
/>,
);
const statusControl = screen.getByRole("combobox", {
name: "Triage status",
});
// When
await user.click(statusControl);
// Then
expect(statusControl).toBeDisabled();
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("should not open an editable empty-note modal for an existing note without a loader", async () => {
// Given
const user = userEvent.setup();
render(
<FindingNoteActionItem
triage={makeTriageSummary({ hasVisibleNote: true })}
findingContext={{ title: "S3 bucket allows public reads" }}
onTriageUpdateAction={vi.fn()}
/>,
);
const existingNoteButton = screen.getByRole("button", {
name: "Open note",
});
// When
await user.click(existingNoteButton);
// Then
expect(existingNoteButton).toBeDisabled();
expect(
screen.queryByRole("dialog", { name: "Add Triage Note" }),
).not.toBeInTheDocument();
});
it("should load an existing note before opening the modal", async () => {
// Given
const user = userEvent.setup();
const onTriageNoteLoadAction = vi.fn().mockResolvedValue({
noteId: "note-1",
noteBody: "Loaded existing note",
});
render(
<FindingNoteActionItem
triage={makeTriageSummary({ hasVisibleNote: true, notesCount: 1 })}
findingContext={{ title: "S3 bucket allows public reads" }}
onTriageUpdateAction={vi.fn()}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Open note" }));
// Then
expect(onTriageNoteLoadAction).toHaveBeenCalledWith(
expect.objectContaining({ triageId: "triage-1", notesCount: 1 }),
);
expect(
await screen.findByRole("dialog", { name: "Add Triage Note" }),
).toBeVisible();
expect(screen.getByLabelText("Note text")).toHaveValue(
"Loaded existing note",
);
});
it("should open a disabled billing upsell modal for Cloud-only Add Triage Note", async () => {
// Given
const user = userEvent.setup();
render(
<FindingNoteActionItem
triage={makeTriageSummary({
canEdit: false,
hasVisibleNote: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
})}
findingContext={{ title: "S3 bucket allows public reads" }}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
// Then
expect(
screen.getByRole("dialog", { name: "Add Triage Note" }),
).toBeVisible();
expect(screen.getByLabelText("Note text")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
});
it("should disable Add Triage Note when no update handler is wired", async () => {
// Given
const user = userEvent.setup();
render(
<FindingNoteActionItem
triage={makeTriageSummary({ hasVisibleNote: false, canEdit: true })}
findingContext={{ title: "S3 bucket allows public reads" }}
/>,
);
const addNoteButton = screen.getByRole("button", {
name: "Add Triage Note",
});
// When
await user.click(addNoteButton);
// Then
expect(addNoteButton).toBeDisabled();
expect(
screen.queryByRole("dialog", { name: "Add Triage Note" }),
).not.toBeInTheDocument();
});
it("should expose a screen-reader error when an existing note cannot load", async () => {
// Given
const user = userEvent.setup();
const onTriageNoteLoadAction = vi
.fn()
.mockRejectedValue(new Error("load failed"));
render(
<FindingNoteActionItem
triage={makeTriageSummary({ hasVisibleNote: true, notesCount: 1 })}
findingContext={{ title: "S3 bucket allows public reads" }}
onTriageUpdateAction={vi.fn()}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Open note" }));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the existing note.",
);
expect(
screen.queryByRole("dialog", { name: "Add Triage Note" }),
).not.toBeInTheDocument();
});
it("should keep the optimistic table status while stale props are rendered during update", async () => {
// Given
const user = userEvent.setup();
let resolveUpdate: () => void = () => {};
const onTriageUpdateAction = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUpdate = resolve;
}),
);
const { rerender } = render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
const statusControl = screen.getByRole("combobox", {
name: "Triage status",
});
// When: user selects a new status.
await user.click(statusControl);
await user.click(screen.getByRole("option", { name: "Under Review" }));
// Then: the optimistic status is visible immediately.
expect(statusControl).toHaveTextContent("Under Review");
// When: the parent renders stale data while the request is still pending.
rerender(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// Then: the control must not flicker back to Open.
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Under Review");
// When: backend completes and fresh props arrive.
resolveUpdate();
await waitFor(() => expect(onTriageUpdateAction).toHaveBeenCalled());
rerender(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// Then
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Under Review");
});
it("should not resurrect a stale optimistic status after the server later returns the previous status", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn().mockResolvedValue(undefined);
const { rerender } = render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// When: user optimistically moves Open -> Under Review and it succeeds.
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "Under Review" }));
await waitFor(() => expect(onTriageUpdateAction).toHaveBeenCalled());
// And: fresh props converge on the optimistic status (server confirmed).
rerender(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// When: a later refetch legitimately returns the previous status again.
rerender(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// Then: the real server status wins; the stale optimistic value is gone.
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Open");
});
it("should refresh the visible table status when triage props change", () => {
// Given
const { rerender } = render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={vi.fn()}
/>,
);
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Open");
// When
rerender(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
})}
onTriageUpdateAction={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Remediating");
});
it("should not submit when table status selection matches the current status", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "Open" }));
// Then
expect(onTriageUpdateAction).not.toHaveBeenCalled();
});
it("should rollback table status and expose an error when update fails", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
const statusControl = screen.getByRole("combobox", {
name: "Triage status",
});
// When
await user.click(statusControl);
await user.click(screen.getByRole("option", { name: "Remediating" }));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not update triage status.",
);
expect(statusControl).toHaveTextContent("Open");
});
it("should not confirm when moving between Mutelist shortcut statuses", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
label: "Risk Accepted",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "False Positive" }));
// Then
expect(screen.queryByRole("dialog", { name: "Mute finding?" })).toBeNull();
await waitFor(() =>
expect(onTriageUpdateAction).toHaveBeenCalledWith(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
isMuted: false,
}),
),
);
});
it("should not confirm or mute again when an already muted finding enters a shortcut status", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
isMuted: true,
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
// Then
expect(screen.queryByRole("dialog", { name: "Mute finding?" })).toBeNull();
await waitFor(() =>
expect(onTriageUpdateAction).toHaveBeenCalledWith(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
isMuted: true,
}),
),
);
});
it("should confirm before applying Mutelist shortcut statuses from the table", async () => {
// Given
const user = userEvent.setup();
let resolveUpdate: () => void = () => {};
const onTriageUpdateAction = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUpdate = resolve;
}),
);
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
const statusControl = screen.getByRole("combobox", {
name: "Triage status",
});
// When: user selects a Mutelist shortcut.
await user.click(statusControl);
await user.click(screen.getByRole("option", { name: "False Positive" }));
// Then: the user is warned before the server action handles muting.
expect(screen.getByRole("dialog", { name: "Mute finding?" })).toBeVisible();
expect(onTriageUpdateAction).not.toHaveBeenCalled();
// When
await user.click(screen.getByRole("button", { name: "Mute finding" }));
// Then
await waitFor(() =>
expect(onTriageUpdateAction).toHaveBeenCalledWith({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
isMuted: false,
}),
);
expect(statusControl).toHaveTextContent("False Positive");
resolveUpdate();
});
});
@@ -0,0 +1,303 @@
"use client";
import { MessageSquareText } from "lucide-react";
import { useState } from "react";
import { ActionDropdownItem } from "@/components/shadcn/dropdown";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageDetail,
type FindingTriageLoadedNote,
type FindingTriageStatus,
type FindingTriageSummary,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
import {
FindingNoteModal,
type FindingTriageContext,
} from "./finding-note-modal";
import {
FindingTriageStatusControl,
type FindingTriageUpdateHandler,
} from "./finding-triage-status-control";
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
const getDisabledCopy = ({
triage,
hasUpdateHandler,
}: {
triage: FindingTriageSummary;
hasUpdateHandler: boolean;
}): string | undefined => {
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) {
return CLOUD_ONLY_TOOLTIP_COPY;
}
if (triage.canEdit && !hasUpdateHandler) {
return EDITING_UNAVAILABLE_COPY;
}
return undefined;
};
const getTriageDetailFromSummary = (
triage: FindingTriageSummary,
loadedNote?: FindingTriageLoadedNote,
): FindingTriageDetail => ({
...triage,
noteId: loadedNote?.noteId ?? null,
noteBody: loadedNote?.noteBody ?? "",
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
});
export function FindingTriageStatusCell({
triage,
onTriageUpdateAction,
}: {
triage?: FindingTriageSummary;
onTriageUpdateAction?: FindingTriageUpdateHandler;
}) {
const [optimisticStatus, setOptimisticStatus] = useState<{
token: string;
findingId: string;
triageId: string | null;
previousStatus: FindingTriageStatus;
status: FindingTriageStatus;
} | 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)
) {
setOptimisticStatus(null);
}
const optimisticMatchesCurrentTriage =
Boolean(triage) &&
optimisticStatus?.findingId === triage?.findingId &&
optimisticStatus?.triageId === triage?.triageId &&
optimisticStatus?.previousStatus === triage?.status &&
optimisticStatus?.status !== triage?.status;
if (!triage) {
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
const displayedTriage =
optimisticMatchesCurrentTriage && optimisticStatus
? {
...triage,
status: optimisticStatus.status,
label: FINDING_TRIAGE_STATUS_LABELS[optimisticStatus.status],
}
: triage;
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
const optimisticToken = input.status ? crypto.randomUUID() : null;
if (input.status && optimisticToken) {
setOptimisticStatus({
token: optimisticToken,
findingId: input.findingId,
triageId: input.triageId,
previousStatus: input.previousStatus ?? triage.status,
status: input.status,
});
}
try {
await onTriageUpdateAction?.(input);
} catch (error) {
setOptimisticStatus((current) =>
current?.token === optimisticToken ? null : current,
);
throw error;
}
};
const control = (
<div
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<FindingTriageStatusControl
key={displayedTriage.findingId}
origin={FINDING_TRIAGE_ORIGIN.TABLE}
triage={displayedTriage}
onTriageUpdateAction={
onTriageUpdateAction ? handleTriageUpdate : undefined
}
/>
</div>
);
const disabledCopy = getDisabledCopy({
triage,
hasUpdateHandler: Boolean(onTriageUpdateAction),
});
if (!disabledCopy) {
return control;
}
return (
<Tooltip>
<TooltipTrigger asChild>
{/* Block-level so the tooltip wrapper doesn't add inline baseline spacing
that would push the compact "Triage" label below the sibling columns. */}
<span className="flex">{control}</span>
</TooltipTrigger>
<TooltipContent>{disabledCopy}</TooltipContent>
</Tooltip>
);
}
export function FindingNoteActionItem({
triage,
findingContext = { title: "Finding" },
onTriageUpdateAction,
onTriageNoteLoadAction,
}: {
triage?: FindingTriageSummary;
findingContext?: FindingTriageContext;
onTriageUpdateAction?: FindingTriageUpdateHandler;
onTriageNoteLoadAction?: (
triage: FindingTriageSummary,
) => Promise<FindingTriageLoadedNote>;
}) {
if (!triage) {
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
const triageIdentity = `${triage.findingId}:${triage.triageId ?? "virtual"}`;
return (
<FindingNoteActionItemContent
key={triageIdentity}
triage={triage}
findingContext={findingContext}
onTriageUpdateAction={onTriageUpdateAction}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>
);
}
function FindingNoteActionItemContent({
triage,
findingContext,
onTriageUpdateAction,
onTriageNoteLoadAction,
}: {
triage: FindingTriageSummary;
findingContext: FindingTriageContext;
onTriageUpdateAction?: FindingTriageUpdateHandler;
onTriageNoteLoadAction?: (
triage: FindingTriageSummary,
) => Promise<FindingTriageLoadedNote>;
}) {
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [loadedNote, setLoadedNote] = useState<FindingTriageLoadedNote>();
const [isLoadingNote, setIsLoadingNote] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const hasUpdateHandler = Boolean(onTriageUpdateAction);
const isCloudOnly =
triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY;
const canOpenNewNoteModal =
!triage.hasVisibleNote &&
((triage.canEdit && hasUpdateHandler) || isCloudOnly);
const canOpenExistingNoteModal =
triage.hasVisibleNote &&
triage.canEdit &&
hasUpdateHandler &&
Boolean(onTriageNoteLoadAction) &&
!isLoadingNote;
const disabledCopy = getDisabledCopy({ triage, hasUpdateHandler });
const canOpenNoteModal = triage.hasVisibleNote
? canOpenExistingNoteModal
: canOpenNewNoteModal;
const label = isLoadingNote
? "Loading note..."
: triage.hasVisibleNote
? "Open note"
: "Add Triage Note";
const handleNoteSelect = async () => {
if (!canOpenNoteModal) {
return;
}
if (!triage.hasVisibleNote) {
setIsNoteModalOpen(true);
return;
}
if (!onTriageNoteLoadAction) {
return;
}
setLoadError(null);
setIsLoadingNote(true);
try {
const note = await onTriageNoteLoadAction(triage);
setLoadedNote(note);
setIsNoteModalOpen(true);
} catch {
setLoadError("Could not load the existing note.");
} finally {
setIsLoadingNote(false);
}
};
const noteModal = isNoteModalOpen ? (
<FindingNoteModal
open={isNoteModalOpen}
onOpenChange={setIsNoteModalOpen}
triage={getTriageDetailFromSummary(triage, loadedNote)}
findingContext={findingContext}
onTriageUpdateAction={onTriageUpdateAction}
/>
) : null;
return (
<>
<ActionDropdownItem
icon={<MessageSquareText className="size-5" />}
label={label}
disabled={!canOpenNoteModal}
title={
triage.hasVisibleNote && !canOpenExistingNoteModal
? "Existing note cannot be loaded from the table."
: disabledCopy
}
onSelect={(event) => {
event.preventDefault();
void handleNoteSelect();
}}
/>
{loadError && (
<span className="sr-only" role="alert">
{loadError}
</span>
)}
{noteModal}
</>
);
}
@@ -0,0 +1,227 @@
"use client";
import { type ComponentProps, useState } from "react";
import { Button } from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/shadcn/select/select";
import { cn } from "@/lib/utils";
import {
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageManualStatus,
type FindingTriageStatus,
type FindingTriageSummary,
isManualStatus,
isMutelistShortcutStatus,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
export type FindingTriageUpdateHandler = (
input: UpdateFindingTriageInput,
) => void | Promise<void>;
type TriageStatusPickerSize = NonNullable<
ComponentProps<typeof SelectTrigger>["size"]
>;
const TRIAGE_STATUS_TEXT_CLASS = {
open: "text-text-error-primary",
under_review: "text-text-warning-primary",
remediating: "text-bg-data-info",
resolved: "text-bg-pass",
risk_accepted: "text-bg-pass",
false_positive: "text-text-neutral-secondary",
reopened: "text-text-error-primary",
} as const satisfies Record<FindingTriageStatus, string>;
const MUTELIST_CONFIRMATION_TITLE = "Mute finding?";
const MUTELIST_CONFIRMATION_COPY =
"Changing to this triage status will mute the finding.";
function TriageStatusPicker({
disabled,
size = "sm",
value,
onValueChange,
}: {
disabled: boolean;
size?: TriageStatusPickerSize;
value: FindingTriageStatus;
onValueChange: (status: FindingTriageManualStatus) => void;
}) {
return (
<Select
value={value}
disabled={disabled}
onValueChange={(nextStatus) => {
if (isManualStatus(nextStatus as FindingTriageStatus)) {
onValueChange(nextStatus as FindingTriageManualStatus);
}
}}
>
<SelectTrigger
aria-label="Triage status"
disabled={disabled}
size={size}
iconSize="sm"
>
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[value])}>
{FINDING_TRIAGE_STATUS_LABELS[value]}
</span>
</SelectTrigger>
<SelectContent>
{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>
))}
</SelectContent>
</Select>
);
}
type TableStatusControlProps = {
origin: typeof FINDING_TRIAGE_ORIGIN.TABLE;
triage: FindingTriageSummary;
onTriageUpdateAction?: FindingTriageUpdateHandler;
};
type ModalStatusControlProps = {
origin: typeof FINDING_TRIAGE_ORIGIN.MODAL;
triage: FindingTriageSummary;
value: FindingTriageStatus;
onValueChange: (status: FindingTriageManualStatus) => void;
};
type FindingTriageStatusControlProps =
| TableStatusControlProps
| ModalStatusControlProps;
export function FindingTriageStatusControl(
props: FindingTriageStatusControlProps,
) {
const [tableUpdateError, setTableUpdateError] = useState<string | null>(null);
const [isTableUpdating, setIsTableUpdating] = useState(false);
const [pendingShortcutStatus, setPendingShortcutStatus] =
useState<FindingTriageManualStatus | null>(null);
const triage = props.triage;
if (props.origin === FINDING_TRIAGE_ORIGIN.MODAL) {
return (
<TriageStatusPicker
disabled={!triage.canEdit}
value={props.value}
onValueChange={props.onValueChange}
/>
);
}
const canMutateFromTable =
triage.canEdit && Boolean(props.onTriageUpdateAction) && !isTableUpdating;
const applyTableStatus = async (status: FindingTriageManualStatus) => {
if (!props.onTriageUpdateAction || status === triage.status) {
return;
}
setTableUpdateError(null);
setIsTableUpdating(true);
try {
await props.onTriageUpdateAction({
findingId: triage.findingId,
findingUid: triage.findingUid,
triageId: triage.triageId,
notesCount: triage.notesCount,
status,
previousStatus: triage.status,
isMuted: triage.isMuted,
});
} catch {
setTableUpdateError("Could not update triage status.");
} finally {
setIsTableUpdating(false);
}
};
const shouldConfirmMute = (status: FindingTriageManualStatus) =>
!triage.isMuted &&
isMutelistShortcutStatus(status) &&
!isMutelistShortcutStatus(triage.status);
const handleTableValueChange = (status: FindingTriageManualStatus) => {
if (!props.onTriageUpdateAction || status === triage.status) {
return;
}
if (shouldConfirmMute(status)) {
setPendingShortcutStatus(status);
return;
}
void applyTableStatus(status);
};
return (
<>
<InfoField label="Triage" variant="compact">
<div className="w-32">
<TriageStatusPicker
disabled={!canMutateFromTable}
size="xs"
value={triage.status}
onValueChange={handleTableValueChange}
/>
</div>
</InfoField>
{tableUpdateError && (
<span className="sr-only" role="alert">
{tableUpdateError}
</span>
)}
<Modal
open={pendingShortcutStatus !== null}
onOpenChange={(open) => {
if (!open) {
setPendingShortcutStatus(null);
}
}}
title={MUTELIST_CONFIRMATION_TITLE}
description={MUTELIST_CONFIRMATION_COPY}
size="sm"
>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setPendingShortcutStatus(null)}
>
Cancel
</Button>
<Button
type="button"
onClick={() => {
const status = pendingShortcutStatus;
setPendingShortcutStatus(null);
if (status) {
void applyTableStatus(status);
}
}}
>
Mute finding
</Button>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
} from "@/types/findings-triage";
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
function makeTriageDetail(
overrides?: Partial<FindingTriageDetail>,
): FindingTriageDetail {
return {
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: "triage-1",
notesCount: 1,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
label: "Under Review",
hasVisibleNote: true,
isMuted: false,
canEdit: true,
billingHref: "https://prowler.com/pricing",
noteId: "note-1",
noteBody: "Existing investigation note",
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
...overrides,
};
}
describe("buildFindingTriageUpdateInput", () => {
it("should return null when neither status nor note changed", () => {
// Given
const triage = makeTriageDetail();
// When
const result = buildFindingTriageUpdateInput({
triage,
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
noteBody: "Existing investigation note",
});
// Then
expect(result).toBeNull();
});
it("should update an existing note through noteId without duplicating note creation", () => {
// Given
const triage = makeTriageDetail();
// When
const result = buildFindingTriageUpdateInput({
triage,
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
noteBody: " Updated existing note ",
});
// Then
expect(result).toEqual({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: "triage-1",
notesCount: 1,
noteId: "note-1",
isMuted: false,
note: "Updated existing note",
});
});
it("should send status plus note only when creating the first note", () => {
// Given
const triage = makeTriageDetail({
triageId: null,
notesCount: 0,
noteId: null,
noteBody: "",
hasVisibleNote: false,
});
// When
const result = buildFindingTriageUpdateInput({
triage,
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
noteBody: " First note ",
});
// Then
expect(result).toEqual({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: null,
notesCount: 0,
noteId: null,
isMuted: false,
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
note: "First note",
});
});
it("should send only status when status changes and an existing note is unchanged", () => {
// Given
const triage = makeTriageDetail();
// When
const result = buildFindingTriageUpdateInput({
triage,
selectedStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
noteBody: "Existing investigation note",
});
// 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.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
});
});
it("should send an empty note when an existing note is cleared", () => {
// Given
const triage = makeTriageDetail();
// When
const result = buildFindingTriageUpdateInput({
triage,
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
noteBody: " ",
});
// Then
expect(result).toEqual({
findingId: "finding-1",
findingUid: "prowler-finding-uid-1",
triageId: "triage-1",
notesCount: 1,
noteId: "note-1",
isMuted: false,
note: "",
});
});
});
@@ -0,0 +1,56 @@
import {
type FindingTriageDetail,
type FindingTriageManualStatus,
type FindingTriageStatus,
isManualStatus,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
export interface BuildFindingTriageUpdateInputParams {
triage: FindingTriageDetail;
selectedStatus: FindingTriageStatus;
noteBody: string;
}
export function buildFindingTriageUpdateInput({
triage,
selectedStatus,
noteBody,
}: BuildFindingTriageUpdateInputParams): UpdateFindingTriageInput | null {
const trimmedNote = noteBody.trim();
const statusChanged = selectedStatus !== triage.status;
const shouldCreateFirstNote =
triage.notesCount === 0 && trimmedNote.length > 0;
const shouldUpdateExistingNote =
triage.notesCount > 0 &&
triage.noteId !== null &&
trimmedNote !== triage.noteBody;
const shouldIncludeStatus =
isManualStatus(selectedStatus) && (statusChanged || shouldCreateFirstNote);
if (
!shouldIncludeStatus &&
!shouldCreateFirstNote &&
!shouldUpdateExistingNote
) {
return null;
}
return {
findingId: triage.findingId,
findingUid: triage.findingUid,
triageId: triage.triageId,
notesCount: triage.notesCount,
noteId: triage.noteId,
isMuted: triage.isMuted,
...(shouldIncludeStatus
? {
status: selectedStatus as FindingTriageManualStatus,
previousStatus: triage.status,
}
: {}),
...(shouldCreateFirstNote || shouldUpdateExistingNote
? { note: trimmedNote }
: {}),
};
}
@@ -8,6 +8,10 @@ import {
import { ChevronLeft } from "lucide-react";
import { useSearchParams } from "next/navigation";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
Table,
@@ -73,6 +77,7 @@ export function FindingsGroupDrillDown({
handleMuteComplete,
handleRowSelectionChange,
resolveSelectedFindingIds,
updateTriageOptimistically,
} = useFindingGroupResourceState({
group,
filters,
@@ -82,6 +87,10 @@ export function FindingsGroupDrillDown({
const columns = getColumnFindingResources({
rowSelection,
selectableRowCount,
findingTitle: group.checkTitle,
onTriageUpdateAction: (input) =>
updateTriageOptimistically(input, updateFindingTriage),
onTriageNoteLoadAction: loadLatestFindingTriageNote,
});
const table = useReactTable({
@@ -249,6 +258,7 @@ export function FindingsGroupDrillDown({
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleDrawerMuteComplete}
onTriageUpdate={drawer.patchTriageUpdate}
/>
</FindingsSelectionContext.Provider>
);
+1
View File
@@ -3,6 +3,7 @@ export * from "./column-finding-resources";
export * from "./column-standalone-findings";
export * from "./data-table-row-actions";
export * from "./finding-detail-drawer";
export * from "./finding-triage-cells";
export * from "./findings-group-drill-down";
export * from "./findings-group-table";
export * from "./findings-selection-context";
@@ -1,16 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("inline resource container", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "inline-resource-container.tsx");
const source = readFileSync(filePath, "utf8");
it("uses the shared finding-group resource state hook", () => {
expect(source).toContain("useFindingGroupResourceState");
expect(source).not.toContain("useInfiniteResources");
});
});
@@ -9,11 +9,16 @@ import { AnimatePresence, motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
import { useImperativeHandle, useRef } from "react";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { TableCell, TableRow } from "@/components/ui/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { cn } from "@/lib/utils";
import { FindingGroupRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
@@ -52,6 +57,22 @@ interface InlineResourceContainerProps {
/** Max skeleton rows that fit in the 440px scroll container */
const MAX_SKELETON_ROWS = 7;
const ACTIONS_COLUMN_ID = "actions";
const COMPACT_LABELED_COLUMN_IDS = new Set([
"service",
"region",
"lastSeen",
"failingFor",
"triage",
]);
const STICKY_RESOURCE_ACTION_CELL_CLASS =
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary";
const getResourceCellClassName = (columnId: string) =>
cn(
COMPACT_LABELED_COLUMN_IDS.has(columnId) && "align-top",
columnId === ACTIONS_COLUMN_ID && STICKY_RESOURCE_ACTION_CELL_CLASS,
);
function ResourceSkeletonRow({
isEmptyStateSized = false,
@@ -111,9 +132,17 @@ function ResourceSkeletonRow({
<TableCell className={cellClassName}>
<Skeleton className="h-4.5 w-16 rounded" />
</TableCell>
{/* Actions */}
{/* Triage */}
<TableCell className={cellClassName}>
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-8 w-20 rounded-lg" />
</TableCell>
{/* Actions */}
<TableCell
className={cn(cellClassName, STICKY_RESOURCE_ACTION_CELL_CLASS)}
>
<div className="flex justify-end">
<Skeleton className="size-8 rounded-md" />
</div>
</TableCell>
</TableRow>
);
@@ -160,6 +189,7 @@ export function InlineResourceContainer({
handleMuteComplete,
handleRowSelectionChange,
resolveSelectedFindingIds,
updateTriageOptimistically,
} = useFindingGroupResourceState({
group,
filters,
@@ -186,6 +216,10 @@ export function InlineResourceContainer({
const columns = getColumnFindingResources({
rowSelection,
selectableRowCount,
findingTitle: group.checkTitle,
onTriageUpdateAction: (input) =>
updateTriageOptimistically(input, updateFindingTriage),
onTriageNoteLoadAction: loadLatestFindingTriageNote,
});
const table = useReactTable({
@@ -214,7 +248,7 @@ export function InlineResourceContainer({
}}
>
<tr>
<td colSpan={columnCount} className="p-0">
<td colSpan={columnCount} className="max-w-0 p-0">
<AnimatePresence initial>
<motion.div
// Onboarding anchor: the "Review the affected resources" tour step.
@@ -228,10 +262,10 @@ export function InlineResourceContainer({
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
className="minimal-scrollbar max-h-[440px] overflow-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<table className="-mt-2.5 w-max min-w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({ length: skeletonRowCount }).map((_, i) => (
@@ -245,7 +279,7 @@ export function InlineResourceContainer({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
className="group cursor-pointer"
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
@@ -260,7 +294,12 @@ export function InlineResourceContainer({
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell
key={cell.id}
className={getResourceCellClassName(
cell.column.id,
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@@ -336,6 +375,7 @@ export function InlineResourceContainer({
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleDrawerMuteComplete}
onTriageUpdate={drawer.patchTriageUpdate}
/>
</FindingsSelectionContext.Provider>
);
@@ -22,6 +22,8 @@ const {
mockClipboardWriteText,
mockSearchParamsState,
mockNotificationIndicator,
mockUpdateFindingTriage,
mockLoadLatestFindingTriageNote,
} = vi.hoisted(() => ({
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
mockGetCompliancesOverview: vi.fn(),
@@ -29,6 +31,8 @@ const {
mockClipboardWriteText: vi.fn(),
mockSearchParamsState: { value: "" },
mockNotificationIndicator: vi.fn(),
mockUpdateFindingTriage: vi.fn(),
mockLoadLatestFindingTriageNote: vi.fn(),
}));
vi.mock("next/navigation", () => ({
@@ -255,6 +259,11 @@ vi.mock("@/actions/compliances", () => ({
getCompliancesOverview: mockGetCompliancesOverview,
}));
vi.mock("@/actions/findings", () => ({
updateFindingTriage: mockUpdateFindingTriage,
loadLatestFindingTriageNote: mockLoadLatestFindingTriageNote,
}));
vi.mock("@/components/icons", () => ({
getComplianceIcon: mockGetComplianceIcon,
}));
@@ -309,8 +318,12 @@ vi.mock("@/components/ui/table", () => ({
TableBody: ({ children }: { children: ReactNode }) => (
<tbody>{children}</tbody>
),
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
TableHead: ({ children }: { children: ReactNode }) => <th>{children}</th>,
TableCell: ({ children, ...props }: HTMLAttributes<HTMLTableCellElement>) => (
<td {...props}>{children}</td>
),
TableHead: ({ children, ...props }: HTMLAttributes<HTMLTableCellElement>) => (
<th {...props}>{children}</th>
),
TableHeader: ({ children }: { children: ReactNode }) => (
<thead>{children}</thead>
),
@@ -360,6 +373,96 @@ vi.mock("../notification-indicator", () => ({
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" } as const,
}));
vi.mock("../finding-triage-cells", () => ({
FindingNoteActionItem: ({
triage,
onTriageUpdateAction,
}: {
triage?: {
findingId: string;
findingUid: string;
triageId: string | null;
notesCount: number;
status: string;
label: string;
isMuted: boolean;
};
onTriageUpdateAction?: (input: {
findingId: string;
findingUid: string;
triageId: string | null;
notesCount: number;
status: string;
previousStatus: string;
isMuted: boolean;
note: string;
}) => Promise<void>;
}) =>
triage ? (
<button
type="button"
onClick={() =>
onTriageUpdateAction?.({
findingId: triage.findingId,
findingUid: triage.findingUid,
triageId: triage.triageId,
notesCount: triage.notesCount,
status: "remediating",
previousStatus: triage.status,
isMuted: triage.isMuted,
note: "Investigating",
})
}
>
Add Triage Note
</button>
) : null,
FindingTriageStatusCell: ({
triage,
onTriageUpdateAction,
}: {
triage?: {
findingId: string;
findingUid: string;
triageId: string | null;
notesCount: number;
status: string;
label: string;
isMuted: boolean;
};
onTriageUpdateAction?: (input: {
findingId: string;
findingUid: string;
triageId: string | null;
notesCount: number;
status: string;
previousStatus: string;
isMuted: boolean;
}) => Promise<void>;
}) =>
triage ? (
<button
type="button"
aria-label="Triage status"
onClick={() =>
onTriageUpdateAction?.({
findingId: triage.findingId,
findingUid: triage.findingUid,
triageId: triage.triageId,
notesCount: triage.notesCount,
status: "remediating",
previousStatus: triage.status,
isMuted: triage.isMuted,
})
}
>
{triage.label}
</button>
) : (
<span>-</span>
),
}));
vi.mock("./resource-detail-skeleton", () => ({
ResourceDetailSkeleton: () => <div data-testid="skeleton" />,
}));
@@ -374,6 +477,10 @@ vi.mock("../../muted", () => ({
import type { ResourceDrawerFinding } from "@/actions/findings";
import type { FindingResourceRow } from "@/types";
import {
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
import type { CheckMeta } from "./use-resource-detail-drawer";
@@ -404,6 +511,24 @@ const mockCheckMeta: CheckMeta = {
additionalUrls: [],
};
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "prowler-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",
...overrides,
};
}
const mockFinding: ResourceDrawerFinding = {
id: "finding-1",
uid: "uid-1",
@@ -479,6 +604,147 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
expect(srOnlyLabel).toHaveTextContent("View Resource");
});
});
describe("ResourceDetailDrawerContent — triage drawer actions", () => {
it("should render Triage and Add Triage Note for other findings rows", () => {
// Given
const otherFinding: ResourceDrawerFinding = {
...mockFinding,
id: "finding-2",
uid: "uid-2",
checkId: "ec2_check",
checkTitle: "EC2 Check",
triage: makeTriageSummary({
findingId: "finding-2",
findingUid: "uid-2",
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
}),
};
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[otherFinding]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const row = screen.getByText("EC2 Check").closest("tr");
expect(row).not.toBeNull();
// Then
expect(screen.getByText("Triage")).toBeInTheDocument();
expect(
within(row as HTMLElement).getByRole("button", {
name: "Triage status",
}),
).toHaveTextContent("Remediating");
expect(
within(row as HTMLElement).getByRole("button", {
name: "Add Triage Note",
}),
).toBeInTheDocument();
expect(
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
).toBeInTheDocument();
expect(
within(row as HTMLElement).getByRole("button", { name: "Send to Jira" }),
).toBeInTheDocument();
});
it("should keep the other findings actions cell sticky on the right edge", () => {
// Given
const otherFinding: ResourceDrawerFinding = {
...mockFinding,
id: "finding-2",
uid: "uid-2",
checkId: "ec2_check",
checkTitle: "EC2 Check",
triage: makeTriageSummary({
findingId: "finding-2",
findingUid: "uid-2",
}),
};
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[otherFinding]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const row = screen.getByText("EC2 Check").closest("tr");
expect(row).not.toBeNull();
const actionsCell = within(row as HTMLElement)
.getByRole("button", { name: "Send to Jira" })
.closest("td");
// Then
expect(actionsCell).toHaveClass("sticky");
expect(actionsCell).toHaveClass("right-0");
expect(actionsCell).toHaveClass("z-20");
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
expect(actionsCell).toHaveClass("before:bg-gradient-to-r");
expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary");
});
it("should update simple drawer triage without using the mute refresh path", async () => {
// Given
const user = userEvent.setup();
const onMuteComplete = vi.fn();
mockUpdateFindingTriage.mockResolvedValue(undefined);
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={{
...mockFinding,
triage: makeTriageSummary(),
}}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={onMuteComplete}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
// Then
expect(mockUpdateFindingTriage).toHaveBeenCalledWith(
expect.objectContaining({
findingId: "finding-1",
status: FINDING_TRIAGE_STATUS.REMEDIATING,
note: "Investigating",
}),
);
expect(onMuteComplete).not.toHaveBeenCalled();
});
});
const mockResourceRow: FindingResourceRow = {
id: "row-1",
rowType: "resource",
@@ -1002,69 +1268,6 @@ describe("ResourceDetailDrawerContent — Risk section styling", () => {
});
});
// ---------------------------------------------------------------------------
// Fix 4: Compliance icon styling should match master
// ---------------------------------------------------------------------------
describe("ResourceDetailDrawerContent — compliance icon styling", () => {
it("should render framework icons inside the same white chip used in master", () => {
// Given
mockGetComplianceIcon.mockImplementation((framework: string) =>
framework === "CIS-1.4" ? "/cis.svg" : null,
);
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const icon = screen.getByRole("img", { name: "CIS-1.4" });
const chip = icon.closest("div");
// Then
expect(chip).toHaveClass("bg-white");
expect(chip).toHaveClass("border-gray-300");
});
it("should render framework fallback pills with the same master styling", () => {
// Given
mockGetComplianceIcon.mockReturnValue(null);
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const chip = screen.getByText("PCI-DSS");
// Then
expect(chip).toHaveClass("bg-white");
expect(chip).toHaveClass("border-gray-300");
});
});
describe("ResourceDetailDrawerContent — compliance navigation", () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -16,7 +16,11 @@ import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { getCompliancesOverview } from "@/actions/compliances";
import type { ResourceDrawerFinding } from "@/actions/findings";
import {
loadLatestFindingTriageNote,
type ResourceDrawerFinding,
updateFindingTriage,
} from "@/actions/findings";
import { MarkdownContainer } from "@/components/findings/markdown-container";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
@@ -69,17 +73,26 @@ import {
} from "@/components/ui/table/status-finding-badge";
import { getFailingForLabel } from "@/lib/date-utils";
import { formatDuration } from "@/lib/date-utils";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { getRegionFlag } from "@/lib/region-flags";
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { FindingResourceRow } from "@/types/findings-table";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import { Muted } from "../../muted";
import { DeltaIndicator } from "../delta-indicator";
import {
FindingNoteActionItem,
FindingTriageStatusCell,
} from "../finding-triage-cells";
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
import type { CheckMeta } from "./use-resource-detail-drawer";
const OTHER_FINDINGS_ACTION_CELL_CLASS =
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary";
/** Strip markdown code fences (```lang ... ```) so CodeSnippet shows clean code. */
function stripCodeFences(code: string): string {
return code
@@ -326,6 +339,7 @@ interface ResourceDetailDrawerContentProps {
onNavigatePrev: () => void;
onNavigateNext: () => void;
onMuteComplete: () => void;
onTriageUpdate?: (input: UpdateFindingTriageInput) => void;
}
export function ResourceDetailDrawerContent({
@@ -341,6 +355,7 @@ export function ResourceDetailDrawerContent({
onNavigatePrev,
onNavigateNext,
onMuteComplete,
onTriageUpdate,
}: ResourceDetailDrawerContentProps) {
const searchParams = useSearchParams();
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
@@ -410,6 +425,7 @@ export function ResourceDetailDrawerContent({
const resourceUid = currentResource?.resourceUid ?? f?.resourceUid;
const resourceService = currentResource?.service ?? f?.resourceService;
const resourceRegion = currentResource?.region ?? f?.resourceRegion;
const findingTriage = f?.triage ?? currentResource?.triage;
const resourceRegionLabel = resourceRegion || "-";
const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null;
const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null;
@@ -455,6 +471,16 @@ export function ResourceDetailDrawerContent({
currentResource?.statusExtended || f?.statusExtended;
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => {
await updateFindingTriage(input);
if (shouldRefreshAfterTriageUpdate(input)) {
onMuteComplete();
return;
}
onTriageUpdate?.(input);
};
const handleOpenCompliance = async (framework: string) => {
if (!complianceScanId || resolvingFramework) {
return;
@@ -800,6 +826,19 @@ export function ResourceDetailDrawerContent({
variant="bordered"
ariaLabel="Resource actions"
>
{findingTriage && (
<FindingNoteActionItem
triage={findingTriage}
findingContext={{
title: checkMeta.checkTitle,
resource: resourceName,
provider: providerAlias,
providerType,
}}
onTriageUpdateAction={handleDrawerTriageUpdate}
onTriageNoteLoadAction={loadLatestFindingTriageNote}
/>
)}
<ActionDropdownItem
icon={
f.isMuted ? (
@@ -1194,6 +1233,11 @@ export function ResourceDetailDrawerContent({
Time
</span>
</TableHead>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Triage
</span>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
@@ -1213,11 +1257,12 @@ export function ResourceDetailDrawerContent({
new Set(prev).add(finding.id),
)
}
onTriageUpdateAction={handleDrawerTriageUpdate}
/>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-16 text-center">
<TableCell colSpan={7} className="h-16 text-center">
<span className="text-text-neutral-tertiary text-sm">
{showSyntheticResourceHint
? "No other findings are available for this IaC resource."
@@ -1403,7 +1448,10 @@ function OtherFindingsNavigationSkeletonRows() {
<TableCell>
<Skeleton className="h-5 w-20 rounded" />
</TableCell>
<TableCell className="w-10">
<TableCell>
<Skeleton className="h-8 w-20 rounded-lg" />
</TableCell>
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
<Skeleton className="h-5 w-5 rounded" />
</TableCell>
</TableRow>
@@ -1495,10 +1543,12 @@ function OtherFindingRow({
finding,
isOptimisticallyMuted,
onMuted,
onTriageUpdateAction,
}: {
finding: ResourceDrawerFinding;
isOptimisticallyMuted: boolean;
onMuted: () => void;
onTriageUpdateAction: (input: UpdateFindingTriageInput) => Promise<void>;
}) {
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
@@ -1526,7 +1576,7 @@ function OtherFindingRow({
findingTitle={finding.checkTitle}
/>
<TableRow
className="cursor-pointer"
className="group cursor-pointer"
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
>
<TableCell className="w-14">
@@ -1559,9 +1609,28 @@ function OtherFindingRow({
<TableCell>
<DateWithTime dateTime={finding.updatedAt} />
</TableCell>
<TableCell className="w-10">
<TableCell>
<FindingTriageStatusCell
triage={finding.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
</TableCell>
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
<div onClick={(e) => e.stopPropagation()}>
<ActionDropdown ariaLabel="Finding actions">
{finding.triage && (
<FindingNoteActionItem
triage={finding.triage}
findingContext={{
title: finding.checkTitle,
resource: finding.resourceName,
provider: finding.providerAlias,
providerType: finding.providerType,
}}
onTriageUpdateAction={onTriageUpdateAction}
onTriageNoteLoadAction={loadLatestFindingTriageNote}
/>
)}
<ActionDropdownItem
icon={
isMuted ? (
@@ -12,6 +12,7 @@ import {
DrawerTitle,
} from "@/components/shadcn";
import type { FindingResourceRow } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
import type { CheckMeta } from "./use-resource-detail-drawer";
@@ -31,6 +32,7 @@ interface ResourceDetailDrawerProps {
onNavigatePrev: () => void;
onNavigateNext: () => void;
onMuteComplete: () => void;
onTriageUpdate?: (input: UpdateFindingTriageInput) => void;
}
export function ResourceDetailDrawer({
@@ -48,6 +50,7 @@ export function ResourceDetailDrawer({
onNavigatePrev,
onNavigateNext,
onMuteComplete,
onTriageUpdate,
}: ResourceDetailDrawerProps) {
return (
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
@@ -76,6 +79,7 @@ export function ResourceDetailDrawer({
onNavigatePrev={onNavigatePrev}
onNavigateNext={onNavigateNext}
onMuteComplete={onMuteComplete}
onTriageUpdate={onTriageUpdate}
/>
)}
</DrawerContent>
@@ -31,6 +31,10 @@ vi.mock("next/navigation", () => ({
import type { ResourceDrawerFinding } from "@/actions/findings";
import type { FindingResourceRow } from "@/types";
import {
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import { useResourceDetailDrawer } from "./use-resource-detail-drawer";
@@ -106,6 +110,24 @@ function makeDrawerFinding(
};
}
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "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",
...overrides,
};
}
// ---------------------------------------------------------------------------
// Fix 2: AbortController cleanup on unmount
// ---------------------------------------------------------------------------
@@ -811,4 +833,82 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
"finding-4",
]);
});
it("should patch current and other finding triage locally without refetching", async () => {
const resources = [makeResource()];
// Given
getFindingByIdMock.mockResolvedValue({ data: ["detail"] });
getLatestFindingsByResourceUidMock.mockResolvedValue({
data: ["resource"],
});
adaptFindingsByResourceResponseMock.mockImplementation(
(response: { data: string[] }) =>
response.data[0] === "detail"
? [
makeDrawerFinding({
id: "finding-1",
triage: makeTriageSummary(),
}),
]
: [
makeDrawerFinding({
id: "finding-2",
uid: "uid-2",
triage: makeTriageSummary({
findingId: "finding-2",
findingUid: "uid-2",
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
}),
}),
],
);
const { result } = renderHook(() =>
useResourceDetailDrawer({
resources,
}),
);
await act(async () => {
result.current.openDrawer(0);
await Promise.resolve();
});
const findingFetchCount = getFindingByIdMock.mock.calls.length;
const resourceFetchCount =
getLatestFindingsByResourceUidMock.mock.calls.length;
// When
act(() => {
result.current.patchTriageUpdate({
findingId: "finding-2",
findingUid: "uid-2",
triageId: "triage-2",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.REMEDIATING,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
isMuted: false,
note: "Investigating",
});
});
// Then
expect(result.current.otherFindings[0]?.triage).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
hasVisibleNote: true,
notesCount: 1,
}),
);
expect(result.current.currentFinding?.triage?.status).toBe(
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
);
expect(getFindingByIdMock).toHaveBeenCalledTimes(findingFetchCount);
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(
resourceFetchCount,
);
});
});
@@ -8,7 +8,13 @@ import {
getLatestFindingsByResourceUid,
type ResourceDrawerFinding,
} from "@/actions/findings";
import {
applyOptimisticTriageSummaryUpdate,
getOptimisticTriageMutedReason,
shouldMarkFindingMutedForTriageUpdate,
} from "@/lib/finding-triage";
import { FindingResourceRow } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
// Keep fast carousel navigations in a loading state for one short beat so
// React doesn't batch away the skeleton frame when switching resources.
@@ -67,6 +73,8 @@ interface UseResourceDetailDrawerReturn {
navigateNext: () => void;
/** Clear cache for current resource and re-fetch (e.g. after muting). */
refetchCurrent: () => void;
/** Patch triage state locally after a successful lightweight triage update. */
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
}
/**
@@ -287,6 +295,62 @@ export function useResourceDetailDrawer({
fetchFindings(resource);
};
const patchFindingTriage = (
finding: ResourceDrawerFinding | null,
input: UpdateFindingTriageInput,
): ResourceDrawerFinding | null => {
if (!finding?.triage || finding.triage.findingId !== input.findingId) {
return finding;
}
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
return {
...finding,
isMuted: shouldMarkMuted ? true : finding.isMuted,
mutedReason:
shouldMarkMuted && input.isMuted !== true && input.status
? getOptimisticTriageMutedReason(input.status)
: finding.mutedReason,
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
};
};
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
currentFindingCacheRef.current.forEach((finding, key) => {
const patchedFinding = patchFindingTriage(finding, input);
if (patchedFinding !== finding) {
currentFindingCacheRef.current.set(key, patchedFinding);
}
});
otherFindingsCacheRef.current.forEach((findings, key) => {
const patchedFindings = findings.map((finding) =>
patchFindingTriage(finding, input),
);
if (
patchedFindings.some((finding, index) => finding !== findings[index])
) {
otherFindingsCacheRef.current.set(
key,
patchedFindings.filter(
(finding): finding is ResourceDrawerFinding => finding !== null,
),
);
}
});
setCurrentFinding((finding) => patchFindingTriage(finding, input));
setOtherFindings((findings) =>
findings
.map((finding) => patchFindingTriage(finding, input))
.filter(
(finding): finding is ResourceDrawerFinding => finding !== null,
),
);
};
const navigateTo = (index: number) => {
const resource = resources[index];
if (!resource) return;
@@ -335,5 +399,6 @@ export function useResourceDetailDrawer({
navigatePrev,
navigateNext,
refetchCurrent,
patchTriageUpdate,
};
}
@@ -4,6 +4,10 @@ import { Row, RowSelectionState } from "@tanstack/react-table";
import { Container, CornerDownRight, Link } from "lucide-react";
import { useState } from "react";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
import { FindingDetailDrawer } from "@/components/findings/table";
import {
@@ -28,8 +32,10 @@ import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { DataTable } from "@/components/ui/table";
import { getGroupLabel } from "@/lib/categories";
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 {
getResourceFindingsColumns,
@@ -100,6 +106,7 @@ export const ResourceDetailContent = ({
hasInitiallyLoaded,
providerOrg,
resourceTags,
patchTriageUpdate,
} = useResourceDrawerBootstrap({
resourceId,
resourceUid: attributes.uid,
@@ -140,6 +147,17 @@ export const ResourceDetailContent = ({
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
};
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
await updateFindingTriage(input);
if (shouldRefreshAfterTriageUpdate(input)) {
setFindingsReloadNonce((value) => value + 1);
return;
}
patchTriageUpdate(input);
};
const failedFindings = findingsData;
const selectableRowCount = failedFindings.filter(
@@ -161,6 +179,8 @@ export const ResourceDetailContent = ({
selectableRowCount,
navigateToFinding,
handleMuteComplete,
handleTriageUpdate,
loadLatestFindingTriageNote,
);
const findingTitle =
@@ -0,0 +1,161 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/findings/table", () => ({
DataTableRowActions: ({
row,
onTriageUpdateAction,
}: {
row: { original: ResourceFinding };
onTriageUpdateAction?: unknown;
}) => (
<button disabled={!row.original.triage || !onTriageUpdateAction}>
{row.original.triage ? "Add Triage Note" : "-"}
</button>
),
FindingTriageStatusCell: ({ triage }: { triage?: { label: string } }) =>
triage ? (
<button aria-label="Triage status">{triage.label}</button>
) : (
<span>-</span>
),
}));
vi.mock("@/components/findings/table/notification-indicator", () => ({
NotificationIndicator: () => null,
}));
vi.mock("@/components/shadcn", () => ({
Checkbox: ({ "aria-label": ariaLabel }: { "aria-label"?: string }) => (
<input type="checkbox" aria-label={ariaLabel} />
),
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <time>{dateTime}</time>,
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
SeverityBadge: ({ severity }: { severity: string }) => (
<span>{severity}</span>
),
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
}));
import {
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
getResourceFindingsColumns,
type ResourceFinding,
} from "./resource-findings-columns";
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "prowler-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",
...overrides,
};
}
function makeFinding(overrides?: Partial<ResourceFinding>): ResourceFinding {
return {
type: "findings",
id: "finding-1",
triage: makeTriageSummary(),
attributes: {
status: "FAIL",
severity: "critical",
muted: false,
updated_at: "2026-03-30T10:05:00Z",
check_metadata: {
checktitle: "S3 public access",
},
},
...overrides,
};
}
function getColumnIds(columns: ReturnType<typeof getResourceFindingsColumns>) {
return columns.map(
(column) =>
(column as { id?: string; accessorKey?: string }).id ??
(column as { id?: string; accessorKey?: string }).accessorKey,
);
}
describe("resource-findings-columns", () => {
it("should render Triage before actions without adding a Notes column", () => {
// Given
const columns = getResourceFindingsColumns({}, 1, vi.fn());
// When
const columnIds = getColumnIds(columns);
// Then
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
expect(columnIds).not.toContain("notes");
});
it("should render triage status and Add Triage Note action from the finding DTO", () => {
// Given
const columns = getResourceFindingsColumns(
{},
1,
vi.fn(),
vi.fn(),
vi.fn(),
);
const triageColumn = columns.find(
(col) => (col as { id?: string }).id === "triage",
);
const actionsColumn = columns.find(
(col) => (col as { id?: string }).id === "actions",
);
if (!triageColumn?.cell || !actionsColumn?.cell) {
throw new Error("triage/actions columns not found");
}
const finding = makeFinding({
triage: makeTriageSummary({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
}),
});
const TriageCell = triageColumn.cell as (props: {
row: { original: ResourceFinding };
}) => ReactNode;
const ActionsCell = actionsColumn.cell as (props: {
row: { original: ResourceFinding };
}) => ReactNode;
// When
render(
<div>
{TriageCell({ row: { original: finding } })}
{ActionsCell({ row: { original: finding } })}
</div>,
);
// Then
expect(
screen.getByRole("button", { name: "Triage status" }),
).toHaveTextContent("Remediating");
expect(
screen.getByRole("button", { name: "Add Triage Note" }),
).toBeEnabled();
});
});
@@ -2,7 +2,11 @@
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { DataTableRowActions } from "@/components/findings/table";
import {
DataTableRowActions,
FindingTriageStatusCell,
} from "@/components/findings/table";
import type { FindingTriageUpdateHandler } from "@/components/findings/table/finding-triage-status-control";
import {
DeltaType,
NotificationIndicator,
@@ -15,10 +19,15 @@ import {
SeverityBadge,
StatusFindingBadge,
} from "@/components/ui/table";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
export interface ResourceFinding {
type: "findings";
id: string;
triage?: FindingTriageSummary;
attributes: {
status: "PASS" | "FAIL" | "MANUAL";
severity: Severity;
@@ -37,6 +46,10 @@ export const getResourceFindingsColumns = (
selectableRowCount: number,
onNavigate: (id: string) => void,
onMuteComplete?: (findingIds: string[]) => void,
onTriageUpdateAction?: FindingTriageUpdateHandler,
onTriageNoteLoadAction?: (
triage: FindingTriageSummary,
) => Promise<FindingTriageLoadedNote>,
): ColumnDef<ResourceFinding>[] => {
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
const isAllSelected =
@@ -136,11 +149,29 @@ export const getResourceFindingsColumns = (
),
enableSorting: false,
},
{
id: "triage",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Triage" />
),
cell: ({ row }) => (
<FindingTriageStatusCell
triage={row.original.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
),
enableSorting: false,
},
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => (
<DataTableRowActions row={row} onMuteComplete={onMuteComplete} />
<DataTableRowActions
row={row}
onMuteComplete={onMuteComplete}
onTriageUpdateAction={onTriageUpdateAction}
onTriageNoteLoadAction={onTriageNoteLoadAction}
/>
),
enableSorting: false,
},
@@ -0,0 +1,115 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getResourceDrawerDataMock } = vi.hoisted(() => ({
getResourceDrawerDataMock: vi.fn(),
}));
vi.mock("@/actions/resources", () => ({
getResourceDrawerData: getResourceDrawerDataMock,
}));
import {
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import type { ResourceFinding } from "./resource-findings-columns";
import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
): FindingTriageSummary {
return {
findingId: "finding-1",
findingUid: "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",
...overrides,
};
}
function makeFinding(overrides?: Partial<ResourceFinding>): ResourceFinding {
return {
type: "findings",
id: "finding-1",
triage: makeTriageSummary(),
attributes: {
status: "FAIL",
severity: "critical",
muted: false,
muted_reason: undefined,
updated_at: "2026-03-30T10:05:00Z",
check_metadata: {
checktitle: "S3 public access",
},
},
...overrides,
};
}
function renderBootstrap(findingsReloadNonce = 0) {
return renderHook(() =>
useResourceDrawerBootstrap({
resourceId: "resource-1",
resourceUid: "resource-uid-1",
providerId: "provider-1",
providerType: "aws",
currentPage: 1,
pageSize: 10,
searchQuery: "",
findingsReloadNonce,
}),
);
}
describe("useResourceDrawerBootstrap", () => {
beforeEach(() => {
vi.clearAllMocks();
getResourceDrawerDataMock.mockResolvedValue({
findings: [makeFinding()],
findingsMeta: null,
providerOrg: null,
resourceTags: {},
});
});
it("should patch finding triage locally without reloading drawer data", async () => {
// Given
const { result } = renderBootstrap();
await waitFor(() => expect(result.current.findingsLoading).toBe(false));
const loadCount = getResourceDrawerDataMock.mock.calls.length;
// When
act(() => {
result.current.patchTriageUpdate({
findingId: "finding-1",
findingUid: "uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.REMEDIATING,
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
isMuted: false,
note: "Investigating",
});
});
// Then
expect(result.current.findingsData[0]?.triage).toEqual(
expect.objectContaining({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
hasVisibleNote: true,
notesCount: 1,
}),
);
expect(getResourceDrawerDataMock).toHaveBeenCalledTimes(loadCount);
});
});
@@ -3,7 +3,13 @@
import { useEffect, useState } from "react";
import { getResourceDrawerData } from "@/actions/resources";
import {
applyOptimisticTriageSummaryUpdate,
getOptimisticTriageMutedReason,
shouldMarkFindingMutedForTriageUpdate,
} 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";
@@ -26,6 +32,7 @@ interface UseResourceDrawerBootstrapReturn {
hasInitiallyLoaded: boolean;
providerOrg: OrganizationResource | null;
resourceTags: Record<string, string>;
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
}
export function useResourceDrawerBootstrap({
@@ -48,6 +55,31 @@ export function useResourceDrawerBootstrap({
null,
);
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
setFindingsData((findings) =>
findings.map((finding) => {
if (!finding.triage || finding.triage.findingId !== input.findingId) {
return finding;
}
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
return {
...finding,
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
attributes: {
...finding.attributes,
muted: shouldMarkMuted ? true : finding.attributes.muted,
muted_reason:
shouldMarkMuted && input.isMuted !== true && input.status
? getOptimisticTriageMutedReason(input.status)
: finding.attributes.muted_reason,
},
};
}),
);
};
useEffect(() => {
let cancelled = false;
@@ -111,5 +143,6 @@ export function useResourceDrawerBootstrap({
hasInitiallyLoaded,
providerOrg,
resourceTags,
patchTriageUpdate,
};
}
@@ -44,10 +44,21 @@ export function ActionDropdown({
}: ActionDropdownProps) {
const [open, setOpen] = useState(false);
// Close dropdown when any ancestor scrolls (capture phase catches all scroll events)
// Close dropdown when any ancestor scrolls (capture phase catches all scroll events),
// but ignore scrolls originating inside a nested dialog (e.g. pasting into a modal
// textarea) so they don't unmount a modal rendered within this menu.
useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
const handleScroll = (event: Event) => {
const target = event.target;
if (
target instanceof Element &&
target.closest('[data-slot="dialog-content"]')
) {
return;
}
setOpen(false);
};
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [open]);
+2
View File
@@ -57,6 +57,8 @@ export const Modal = ({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={onOpenAutoFocus}
// Radix requires an accessible description; opt out explicitly when none is provided.
{...(description ? {} : { "aria-describedby": undefined })}
className={cn(
"border-text-neutral-tertiary bg-bg-neutral-secondary rounded-[24px] border shadow-[0_0_200px_0_rgba(15,44,46,0.50)]",
scrollable && "max-h-[90dvh] overflow-y-auto",
@@ -83,6 +83,43 @@ describe("MultiSelect", () => {
).not.toBeInTheDocument();
});
it("uses a selected background instead of a check icon for active items", async () => {
// Given
const user = userEvent.setup();
render(
<MultiSelect values={["aws-prod"]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
// When
await user.click(screen.getByRole("combobox"));
// Then
const selectedItem = screen.getByRole("option", {
name: "Production AWS",
});
expect(selectedItem).toHaveAttribute("data-state", "checked");
expect(selectedItem).toHaveClass(
"data-[state=checked]:bg-button-tertiary/10",
);
expect(selectedItem).not.toHaveClass(
"data-[state=checked]:bg-bg-neutral-tertiary",
);
expect(selectedItem).toHaveClass(
"data-[state=checked]:hover:bg-button-tertiary/15",
);
expect(selectedItem).toHaveClass("hover:bg-slate-200");
expect(selectedItem).toHaveClass("dark:hover:bg-slate-700/50");
expect(selectedItem.querySelector("svg")).toBeNull();
});
it("filters items without crashing when search is enabled", async () => {
const user = userEvent.setup();
@@ -181,39 +218,6 @@ describe("MultiSelect", () => {
expect(screen.getByPlaceholderText("Search accounts...")).toHaveValue("");
});
it("closes the dropdown when clicking outside", async () => {
// Given
const user = userEvent.setup();
render(
<div>
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
}}
>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>
<button type="button">Outside target</button>
</div>,
);
// When
await user.click(screen.getByRole("combobox"));
expect(screen.getByPlaceholderText("Search accounts...")).toBeVisible();
await user.click(screen.getByRole("button", { name: /outside target/i }));
// Then
expect(
screen.queryByPlaceholderText("Search accounts..."),
).not.toBeInTheDocument();
});
it("sizes the dropdown to its content with a capped width", async () => {
const user = userEvent.setup();
@@ -237,46 +241,6 @@ describe("MultiSelect", () => {
expect(screen.getByRole("dialog")).toHaveClass("sm:max-w-[22rem]");
});
it("keeps long option lists scrollable inside the dropdown", async () => {
// Given
const user = userEvent.setup();
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
{Array.from({ length: 20 }, (_, index) => (
<MultiSelectItem key={index} value={`account-${index}`}>
Account {index}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>,
);
// When
await user.click(screen.getByRole("combobox"));
// Then
const list = screen
.getByRole("dialog")
.querySelector('[data-slot="command-list"]');
expect(screen.getByRole("dialog")).toHaveStyle({
maxHeight:
"min(360px, var(--radix-popover-content-available-height, 360px))",
});
expect(list).toHaveClass("minimal-scrollbar");
expect(list).toHaveStyle({
maxHeight:
"min(300px, var(--radix-popover-content-available-height, 300px))",
});
expect(list).toHaveClass("overflow-y-auto");
expect(list).toHaveClass("overscroll-contain");
});
it("keeps the legacy clear-all behavior by default", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
+3 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { CheckIcon, ChevronDown, XIcon } from "lucide-react";
import { ChevronDown, XIcon } from "lucide-react";
import {
type ComponentPropsWithoutRef,
createContext,
@@ -411,12 +411,12 @@ export function MultiSelectItem({
disabled={disabled}
aria-disabled={disabled}
data-disabled={disabled ? "true" : undefined}
data-state={isSelected ? "checked" : "unchecked"}
value={value}
keywords={keywords}
data-slot="multiselect-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
isSelected && "bg-slate-100 dark:bg-slate-800/50",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 data-[selected=true]:data-[state=checked]:bg-button-tertiary/15 my-1 flex w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
className,
)}
@@ -429,12 +429,6 @@ export function MultiSelectItem({
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
{children}
</span>
<CheckIcon
className={cn(
"text-bg-button-secondary size-5 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
}
@@ -0,0 +1,84 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it } from "vitest";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./select";
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: () => false,
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: () => {},
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => {},
});
});
describe("Select", () => {
it("supports an extra-small trigger size", () => {
// Given / When
render(
<Select value="open">
<SelectTrigger aria-label="Compact status" size="xs">
Open
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
</SelectContent>
</Select>,
);
// Then
const trigger = screen.getByRole("combobox", { name: "Compact status" });
expect(trigger).toHaveAttribute("data-size", "xs");
expect(trigger).toHaveClass(
"data-[size=xs]:h-8",
"data-[size=xs]:px-3",
"data-[size=xs]:py-0",
"data-[size=xs]:text-xs",
);
});
it("uses a selected background instead of a check icon for the active item", async () => {
// Given
const user = userEvent.setup();
render(
<Select value="under_review">
<SelectTrigger aria-label="Triage status">Under Review</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="under_review">Under Review</SelectItem>
</SelectContent>
</Select>,
);
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
// Then
const selectedItem = screen.getByRole("option", {
name: "Under Review",
});
expect(selectedItem).toHaveAttribute("data-state", "checked");
expect(selectedItem).toHaveClass(
"data-[state=checked]:bg-button-tertiary/10",
);
expect(selectedItem).not.toHaveClass(
"data-[state=checked]:bg-bg-neutral-tertiary",
);
expect(selectedItem).toHaveClass(
"data-[state=checked]:hover:bg-button-tertiary/15",
);
expect(selectedItem).toHaveClass("hover:bg-slate-200");
expect(selectedItem).toHaveClass("dark:hover:bg-slate-700/50");
expect(
within(selectedItem).queryByRole("img", { hidden: true }),
).toBeNull();
expect(selectedItem.querySelector("svg")).toBeNull();
});
});
+24 -11
View File
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { ComponentProps, type WheelEvent } from "react";
import { cn } from "@/lib/utils";
@@ -10,6 +10,22 @@ const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
event.stopPropagation();
};
const SELECT_TRIGGER_SIZES = {
XS: "xs",
SM: "sm",
DEFAULT: "default",
} as const;
const SELECT_TRIGGER_ICON_SIZES = {
SM: "sm",
DEFAULT: "default",
} as const;
type SelectTriggerSize =
(typeof SELECT_TRIGGER_SIZES)[keyof typeof SELECT_TRIGGER_SIZES];
type SelectTriggerIconSize =
(typeof SELECT_TRIGGER_ICON_SIZES)[keyof typeof SELECT_TRIGGER_ICON_SIZES];
function Select({
allowDeselect = false,
...props
@@ -49,20 +65,20 @@ function SelectValue({
function SelectTrigger({
className,
size = "default",
iconSize = "default",
size = SELECT_TRIGGER_SIZES.DEFAULT,
iconSize = SELECT_TRIGGER_ICON_SIZES.DEFAULT,
children,
...props
}: ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
iconSize?: "sm" | "default";
size?: SelectTriggerSize;
iconSize?: SelectTriggerIconSize;
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 data-[size=xs]:h-8 data-[size=xs]:gap-1.5 data-[size=xs]:px-3 data-[size=xs]:py-0 data-[size=xs]:text-xs *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
@@ -72,7 +88,7 @@ function SelectTrigger({
<ChevronDownIcon
className={cn(
"text-bg-button-secondary shrink-0 opacity-70 transition-transform duration-200 group-data-[state=open]:rotate-180",
iconSize === "sm" ? "size-4" : "size-6",
iconSize === SELECT_TRIGGER_ICON_SIZES.SM ? "size-4" : "size-6",
)}
aria-hidden="true"
/>
@@ -162,7 +178,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-12 pl-4 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-4 pl-4 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
className,
)}
{...props}
@@ -172,9 +188,6 @@ function SelectItem({
{children}
</span>
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator asChild>
<CheckIcon className="text-bg-button-secondary absolute right-4 size-5" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
@@ -1,4 +1,4 @@
import { renderHook } from "@testing-library/react";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useFindingGroupResourcesMock, useResourceDetailDrawerMock } =
@@ -24,7 +24,12 @@ vi.mock("@/components/findings/table/resource-detail-drawer", () => ({
useResourceDetailDrawer: useResourceDetailDrawerMock,
}));
import { type FindingGroupRow, FINDINGS_ROW_TYPE } from "@/types";
import {
type FindingGroupRow,
type FindingResourceRow,
FINDINGS_ROW_TYPE,
} from "@/types";
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import { useFindingGroupResourceState } from "./use-finding-group-resource-state";
@@ -123,4 +128,84 @@ describe("useFindingGroupResourceState", () => {
}),
);
});
it("preserves an existing mute reason for already-muted optimistic shortcut updates", async () => {
// Given
const mutedResource: FindingResourceRow = {
id: "resource-1",
rowType: FINDINGS_ROW_TYPE.RESOURCE,
findingId: "finding-1",
checkId: "check-1",
providerType: "aws",
providerAlias: "production",
providerUid: "provider-1",
resourceName: "resource-1",
resourceType: "Bucket",
resourceGroup: "default",
resourceUid: "resource-uid-1",
service: "s3",
region: "us-east-1",
severity: "high",
status: "MUTED",
statusExtended: "Muted finding",
delta: null,
isMuted: true,
mutedReason: "Existing mute rule",
firstSeenAt: null,
lastSeenAt: "2026-04-22T10:00:00Z",
triage: {
findingId: "finding-1",
findingUid: "finding-uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.OPEN,
label: "Open",
hasVisibleNote: false,
isMuted: true,
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([mutedResource], false);
});
// When
await act(async () => {
await result.current.updateTriageOptimistically(
{
findingId: "finding-1",
findingUid: "finding-uid-1",
triageId: "triage-1",
notesCount: 0,
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
isMuted: true,
},
async () => undefined,
);
});
// Then
expect(result.current.resources[0]).toEqual(
expect.objectContaining({
isMuted: true,
mutedReason: "Existing mute rule",
}),
);
});
});
+131 -3
View File
@@ -1,13 +1,19 @@
"use client";
import { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
import { useState } from "react";
import { useRef, useState } from "react";
import { canMuteFindingResource } from "@/components/findings/table/finding-resource-selection";
import { useResourceDetailDrawer } from "@/components/findings/table/resource-detail-drawer";
import { useFindingGroupResources } from "@/hooks/use-finding-group-resources";
import { applyDefaultMutedFilter } from "@/lib";
import {
applyOptimisticTriageSummaryUpdate,
getOptimisticTriageMutedReason,
shouldMarkFindingMutedForTriageUpdate,
} from "@/lib/finding-triage";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
interface UseFindingGroupResourceStateOptions {
group: FindingGroupRow;
@@ -35,6 +41,10 @@ interface UseFindingGroupResourceStateReturn {
handleMuteComplete: () => void;
handleRowSelectionChange: OnChangeFn<RowSelectionState>;
resolveSelectedFindingIds: (ids: string[]) => Promise<string[]>;
updateTriageOptimistically: (
input: UpdateFindingTriageInput,
updateAction: (input: UpdateFindingTriageInput) => Promise<void>,
) => Promise<void>;
}
export function useFindingGroupResourceState({
@@ -47,12 +57,79 @@ export function useFindingGroupResourceState({
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [resources, setResources] = useState<FindingResourceRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const baseResourcesRef = useRef<FindingResourceRow[]>([]);
const optimisticTriageByFindingIdRef = useRef(
new Map<string, { token: string; input: UpdateFindingTriageInput }>(),
);
const settledOptimisticFindingIdsRef = useRef(new Set<string>());
const mergeOptimisticTriage = (items: FindingResourceRow[]) =>
items.map((resource) => {
const optimisticEntry = optimisticTriageByFindingIdRef.current.get(
resource.findingId,
);
const optimistic = optimisticEntry?.input;
if (!optimistic || !resource.triage) {
return resource;
}
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(optimistic);
const shouldSetTriageMuteReason =
shouldMarkMuted && optimistic.isMuted !== true;
return {
...resource,
isMuted: shouldMarkMuted ? true : resource.isMuted,
mutedReason: shouldSetTriageMuteReason
? getOptimisticTriageMutedReason(optimistic.status!)
: resource.mutedReason,
triage: applyOptimisticTriageSummaryUpdate(resource.triage, optimistic),
};
});
const removeOptimisticEntry = (findingId: string) => {
optimisticTriageByFindingIdRef.current.delete(findingId);
settledOptimisticFindingIdsRef.current.delete(findingId);
};
const resourceSatisfiesOptimisticUpdate = (
resource: FindingResourceRow,
optimistic: UpdateFindingTriageInput,
) => {
const statusMatches =
!optimistic.status || resource.triage?.status === optimistic.status;
const noteMatches =
!optimistic.note ||
Boolean(resource.triage?.hasVisibleNote) ||
(resource.triage?.notesCount ?? 0) > 0;
return statusMatches && noteMatches;
};
const clearSettledOptimisticUpdates = (items: FindingResourceRow[]) => {
for (const resource of items) {
const optimistic = optimisticTriageByFindingIdRef.current.get(
resource.findingId,
)?.input;
if (
optimistic &&
settledOptimisticFindingIdsRef.current.has(resource.findingId) &&
resourceSatisfiesOptimisticUpdate(resource, optimistic)
) {
removeOptimisticEntry(resource.findingId);
}
}
};
const handleSetResources = (
newResources: FindingResourceRow[],
_hasMore: boolean,
) => {
setResources(newResources);
clearSettledOptimisticUpdates(newResources);
baseResourcesRef.current = newResources;
setResources(mergeOptimisticTriage(baseResourcesRef.current));
setIsLoading(false);
};
@@ -60,7 +137,9 @@ export function useFindingGroupResourceState({
newResources: FindingResourceRow[],
_hasMore: boolean,
) => {
setResources((prev) => [...prev, ...newResources]);
clearSettledOptimisticUpdates(newResources);
baseResourcesRef.current = [...baseResourcesRef.current, ...newResources];
setResources(mergeOptimisticTriage(baseResourcesRef.current));
setIsLoading(false);
};
@@ -141,6 +220,54 @@ export function useFindingGroupResourceState({
return ids.filter(Boolean);
};
const applyOptimisticTriageUpdate = (input: UpdateFindingTriageInput) => {
removeOptimisticEntry(input.findingId);
const token = crypto.randomUUID();
optimisticTriageByFindingIdRef.current.set(input.findingId, {
token,
input,
});
setResources(mergeOptimisticTriage(baseResourcesRef.current));
return token;
};
const clearOptimisticTriageUpdate = (findingId: string, token: string) => {
if (
optimisticTriageByFindingIdRef.current.get(findingId)?.token !== token
) {
return;
}
removeOptimisticEntry(findingId);
setResources(mergeOptimisticTriage(baseResourcesRef.current));
};
const settleOptimisticTriageUpdate = (findingId: string, token: string) => {
if (
optimisticTriageByFindingIdRef.current.get(findingId)?.token !== token
) {
return;
}
settledOptimisticFindingIdsRef.current.add(findingId);
};
const updateTriageOptimistically = async (
input: UpdateFindingTriageInput,
updateAction: (input: UpdateFindingTriageInput) => Promise<void>,
) => {
const optimisticToken = applyOptimisticTriageUpdate(input);
try {
await updateAction(input);
settleOptimisticTriageUpdate(input.findingId, optimisticToken);
refresh();
} catch (error) {
clearOptimisticTriageUpdate(input.findingId, optimisticToken);
refresh();
throw error;
}
};
return {
rowSelection,
resources,
@@ -159,5 +286,6 @@ export function useFindingGroupResourceState({
handleMuteComplete,
handleRowSelectionChange,
resolveSelectedFindingIds,
updateTriageOptimistically,
};
}
+1
View File
@@ -59,5 +59,6 @@ export function findingToFindingResourceRow(
mutedReason: finding.attributes.muted_reason,
firstSeenAt: finding.attributes.first_seen_at,
lastSeenAt: finding.attributes.updated_at,
triage: finding.triage,
};
}
+47
View File
@@ -0,0 +1,47 @@
import {
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageSummary,
isMutelistShortcutStatus,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
export const shouldMarkFindingMutedForTriageUpdate = (
input: UpdateFindingTriageInput,
): boolean => Boolean(input.status && isMutelistShortcutStatus(input.status));
export const shouldRefreshAfterTriageUpdate = (
input: UpdateFindingTriageInput,
): boolean =>
shouldMarkFindingMutedForTriageUpdate(input) && input.isMuted !== true;
export const getOptimisticTriageMutedReason = (
status: NonNullable<UpdateFindingTriageInput["status"]>,
): string =>
`Finding triage status changed to ${FINDING_TRIAGE_STATUS_LABELS[status]}.`;
export const applyOptimisticTriageSummaryUpdate = (
triage: FindingTriageSummary,
input: UpdateFindingTriageInput,
): FindingTriageSummary => {
const noteWasUpdated = Object.prototype.hasOwnProperty.call(input, "note");
const noteHasContent =
typeof input.note === "string" && input.note.length > 0;
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
return {
...triage,
...(input.status
? {
status: input.status,
label: FINDING_TRIAGE_STATUS_LABELS[input.status],
isMuted: shouldMarkMuted ? true : triage.isMuted,
}
: {}),
...(noteWasUpdated
? {
hasVisibleNote: noteHasContent,
notesCount: noteHasContent ? Math.max(triage.notesCount, 1) : 0,
}
: {}),
};
};
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { calculatePercentage, getOptionalText } from "@/lib/utils";
describe("calculatePercentage", () => {
it("rounds the percentage to the nearest integer", () => {
expect(calculatePercentage(1, 3)).toBe(33);
expect(calculatePercentage(2, 3)).toBe(67);
});
it("returns 0 when the total is 0", () => {
expect(calculatePercentage(5, 0)).toBe(0);
});
});
describe("getOptionalText", () => {
it("returns the string when it has usable content", () => {
expect(getOptionalText("my-resource")).toBe("my-resource");
});
it("returns undefined for the '-' placeholder", () => {
expect(getOptionalText("-")).toBeUndefined();
});
it("returns undefined for empty or whitespace-only strings", () => {
expect(getOptionalText("")).toBeUndefined();
expect(getOptionalText(" ")).toBeUndefined();
});
it("returns undefined for non-string values", () => {
expect(getOptionalText(undefined)).toBeUndefined();
expect(getOptionalText(null)).toBeUndefined();
expect(getOptionalText(42)).toBeUndefined();
});
});
+11
View File
@@ -17,3 +17,14 @@ export function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.round((value / total) * 100);
}
/**
* Normalizes a value into an optional display string.
* @param value - The value to normalize
* @returns The original string when it is non-empty and not the "-" placeholder, otherwise undefined
*/
export function getOptionalText(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 && value !== "-"
? value
: undefined;
}
+3
View File
@@ -3,6 +3,8 @@ import { MouseEvent, SVGProps } from "react";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import type { FindingTriageSummary } from "./findings-triage";
export type IconSvgProps = SVGProps<SVGSVGElement> & {
size?: number;
};
@@ -612,6 +614,7 @@ export interface FindingsResponse {
export interface FindingProps {
type: "findings";
id: string;
triage?: FindingTriageSummary;
attributes: {
uid: string;
delta: FindingDelta;
+2
View File
@@ -1,4 +1,5 @@
import { FindingStatus, Severity } from "./components";
import type { FindingTriageSummary } from "./findings-triage";
import { ProviderType } from "./providers";
export const FINDINGS_ROW_TYPE = {
@@ -66,6 +67,7 @@ export interface FindingResourceRow {
mutedReason?: string;
firstSeenAt: string | null;
lastSeenAt: string | null;
triage?: FindingTriageSummary;
}
export type FindingsTableRow = FindingGroupRow | FindingResourceRow;
+113
View File
@@ -0,0 +1,113 @@
export const FINDING_TRIAGE_STATUS = {
OPEN: "open",
UNDER_REVIEW: "under_review",
REMEDIATING: "remediating",
RESOLVED: "resolved",
RISK_ACCEPTED: "risk_accepted",
FALSE_POSITIVE: "false_positive",
REOPENED: "reopened",
} as const;
export type FindingTriageStatus =
(typeof FINDING_TRIAGE_STATUS)[keyof typeof FINDING_TRIAGE_STATUS];
export const FINDING_TRIAGE_STATUS_LABELS = {
[FINDING_TRIAGE_STATUS.OPEN]: "Open",
[FINDING_TRIAGE_STATUS.UNDER_REVIEW]: "Under Review",
[FINDING_TRIAGE_STATUS.REMEDIATING]: "Remediating",
[FINDING_TRIAGE_STATUS.RESOLVED]: "Resolved",
[FINDING_TRIAGE_STATUS.RISK_ACCEPTED]: "Risk Accepted",
[FINDING_TRIAGE_STATUS.FALSE_POSITIVE]: "False Positive",
[FINDING_TRIAGE_STATUS.REOPENED]: "Reopened",
} as const satisfies Record<FindingTriageStatus, string>;
export const FINDING_TRIAGE_MANUAL_STATUS_VALUES = [
FINDING_TRIAGE_STATUS.OPEN,
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
FINDING_TRIAGE_STATUS.REMEDIATING,
FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
] as const;
export type FindingTriageManualStatus =
(typeof FINDING_TRIAGE_MANUAL_STATUS_VALUES)[number];
export const FINDING_TRIAGE_AUTOMATION_STATUS_VALUES = [
FINDING_TRIAGE_STATUS.RESOLVED,
FINDING_TRIAGE_STATUS.REOPENED,
] as const;
export const FINDING_TRIAGE_MUTELIST_SHORTCUT_STATUS_VALUES = [
FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
] as const;
export const isManualStatus = (
status: unknown,
): status is FindingTriageManualStatus => {
return FINDING_TRIAGE_MANUAL_STATUS_VALUES.some((value) => value === status);
};
export const isMutelistShortcutStatus = (status: unknown): boolean => {
return FINDING_TRIAGE_MUTELIST_SHORTCUT_STATUS_VALUES.some(
(value) => value === status,
);
};
export const FINDING_TRIAGE_DISABLED_REASON = {
CLOUD_ONLY: "cloud_only",
FORBIDDEN: "forbidden",
LOADING: "loading",
} as const;
export type FindingTriageDisabledReason =
(typeof FINDING_TRIAGE_DISABLED_REASON)[keyof typeof FINDING_TRIAGE_DISABLED_REASON];
export const FINDING_TRIAGE_ORIGIN = {
TABLE: "table",
MODAL: "modal",
} as const;
export const FINDING_TRIAGE_NOTE_MAX_LENGTH = 500 as const;
export const FINDING_TRIAGE_NOTE_PRIVACY_COPY =
"This note is only visible to your team." as const;
export const FINDING_TRIAGE_BILLING_HREF =
"https://prowler.com/pricing" as const;
export interface FindingTriageSummary {
findingId: string;
findingUid: string;
triageId: string | null;
notesCount: number;
status: FindingTriageStatus;
label: string;
hasVisibleNote: boolean;
isMuted: boolean;
canEdit: boolean;
disabledReason?: FindingTriageDisabledReason;
billingHref: string;
}
export interface FindingTriageDetail extends FindingTriageSummary {
noteId: string | null;
noteBody: string;
maxNoteLength: typeof FINDING_TRIAGE_NOTE_MAX_LENGTH;
privacyCopy: typeof FINDING_TRIAGE_NOTE_PRIVACY_COPY;
}
export interface UpdateFindingTriageInput {
findingId: string;
findingUid: string;
triageId: string | null;
notesCount: number;
noteId?: string | null;
status?: FindingTriageManualStatus;
previousStatus?: FindingTriageStatus;
isMuted?: boolean;
note?: string;
}
export interface FindingTriageLoadedNote {
noteId: string;
noteBody: string;
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./authFormSchema";
export * from "./components";
export * from "./filters";
export * from "./findings-table";
export * from "./findings-triage";
export * from "./formSchemas";
export * from "./organizations";
export * from "./processors";