From 587187419f6b494fa7edeba0051ba699a7fab002 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 1 Jul 2026 17:55:33 +0200 Subject: [PATCH] feat(ui): add findings triage (#11704) Co-authored-by: alejandrobailo --- .../finding-groups.adapter.test.ts | 269 +++++-- .../finding-groups/finding-groups.adapter.ts | 38 +- .../findings-by-resource.adapter.test.ts | 48 +- .../findings/findings-by-resource.adapter.ts | 11 +- .../findings/findings-triage.adapter.test.ts | 418 +++++++++++ .../findings/findings-triage.adapter.ts | 220 ++++++ .../findings/findings-triage.fixtures.ts | 101 +++ .../findings/findings-triage.options.ts | 20 + ui/actions/findings/findings-triage.test.ts | 644 +++++++++++++++++ ui/actions/findings/findings-triage.ts | 264 +++++++ ui/actions/findings/findings.test.ts | 100 +++ ui/actions/findings/findings.ts | 18 +- ui/actions/findings/index.ts | 1 + ui/actions/mute-rules/mute-rules.ts | 21 +- ui/actions/resources/resources.test.ts | 31 +- ui/app/(prowler)/mutelist/mutelist-tabs.tsx | 11 +- .../compliance-header/scan-selector.tsx | 6 +- .../table/column-finding-groups.test.tsx | 126 ++-- .../table/column-finding-resources.test.tsx | 317 +++++++-- .../table/column-finding-resources.tsx | 74 +- .../table/column-standalone-findings.test.tsx | 125 ++++ .../table/column-standalone-findings.tsx | 69 +- .../table/data-table-row-actions.test.tsx | 148 +++- .../findings/table/data-table-row-actions.tsx | 56 +- .../findings/table/finding-detail-drawer.tsx | 2 + .../table/finding-note-modal.test.tsx | 330 +++++++++ .../findings/table/finding-note-modal.tsx | 228 ++++++ .../table/finding-triage-cells.test.tsx | 671 ++++++++++++++++++ .../findings/table/finding-triage-cells.tsx | 303 ++++++++ .../table/finding-triage-status-control.tsx | 227 ++++++ .../table/finding-triage-submit.test.ts | 150 ++++ .../findings/table/finding-triage-submit.ts | 56 ++ .../table/findings-group-drill-down.tsx | 10 + ui/components/findings/table/index.ts | 1 + .../table/inline-resource-container.test.ts | 16 - .../table/inline-resource-container.tsx | 54 +- .../resource-detail-drawer-content.test.tsx | 333 +++++++-- .../resource-detail-drawer-content.tsx | 79 ++- .../resource-detail-drawer.tsx | 4 + .../use-resource-detail-drawer.test.ts | 100 +++ .../use-resource-detail-drawer.ts | 65 ++ .../table/resource-detail-content.tsx | 20 + .../table/resource-findings-columns.test.tsx | 161 +++++ .../table/resource-findings-columns.tsx | 35 +- .../use-resource-drawer-bootstrap.test.ts | 115 +++ .../table/use-resource-drawer-bootstrap.ts | 33 + .../shadcn/dropdown/action-dropdown.tsx | 15 +- ui/components/shadcn/modal/modal.tsx | 2 + .../shadcn/select/multiselect.test.tsx | 110 +-- ui/components/shadcn/select/multiselect.tsx | 12 +- ui/components/shadcn/select/select.test.tsx | 84 +++ ui/components/shadcn/select/select.tsx | 35 +- .../use-finding-group-resource-state.test.ts | 89 ++- ui/hooks/use-finding-group-resource-state.ts | 134 +++- ui/lib/finding-detail.ts | 1 + ui/lib/finding-triage.ts | 47 ++ ui/lib/utils.test.ts | 35 + ui/lib/utils.ts | 11 + ui/types/components.ts | 3 + ui/types/findings-table.ts | 2 + ui/types/findings-triage.ts | 113 +++ ui/types/index.ts | 1 + 62 files changed, 6359 insertions(+), 464 deletions(-) create mode 100644 ui/actions/findings/findings-triage.adapter.test.ts create mode 100644 ui/actions/findings/findings-triage.adapter.ts create mode 100644 ui/actions/findings/findings-triage.fixtures.ts create mode 100644 ui/actions/findings/findings-triage.options.ts create mode 100644 ui/actions/findings/findings-triage.test.ts create mode 100644 ui/actions/findings/findings-triage.ts create mode 100644 ui/actions/findings/findings.test.ts create mode 100644 ui/components/findings/table/column-standalone-findings.test.tsx create mode 100644 ui/components/findings/table/finding-note-modal.test.tsx create mode 100644 ui/components/findings/table/finding-note-modal.tsx create mode 100644 ui/components/findings/table/finding-triage-cells.test.tsx create mode 100644 ui/components/findings/table/finding-triage-cells.tsx create mode 100644 ui/components/findings/table/finding-triage-status-control.tsx create mode 100644 ui/components/findings/table/finding-triage-submit.test.ts create mode 100644 ui/components/findings/table/finding-triage-submit.ts delete mode 100644 ui/components/findings/table/inline-resource-container.test.ts create mode 100644 ui/components/resources/table/resource-findings-columns.test.tsx create mode 100644 ui/components/resources/table/use-resource-drawer-bootstrap.test.ts create mode 100644 ui/components/shadcn/select/select.test.tsx create mode 100644 ui/lib/finding-triage.ts create mode 100644 ui/lib/utils.test.ts create mode 100644 ui/types/findings-triage.ts diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts index c9b4a0314c..e4dcb66804 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.test.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -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) => { + 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, + ); }); }); @@ -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, + ); + }); + + 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", () => { diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index 6b78bc189a..e8d42beaa5 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -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 => + 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], })); } diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index a94f34a9c4..24be4cd6ed 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -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, + }), + ); }); }); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 0312df00da..3ebae3c6c5 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -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; 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) || "-", diff --git a/ui/actions/findings/findings-triage.adapter.test.ts b/ui/actions/findings/findings-triage.adapter.test.ts new file mode 100644 index 0000000000..6df034cc40 --- /dev/null +++ b/ui/actions/findings/findings-triage.adapter.test.ts @@ -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) => { + 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); + 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); + }); +}); diff --git a/ui/actions/findings/findings-triage.adapter.ts b/ui/actions/findings/findings-triage.adapter.ts new file mode 100644 index 0000000000..f00db7e33b --- /dev/null +++ b/ui/actions/findings/findings-triage.adapter.ts @@ -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 => + 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, + }; +} diff --git a/ui/actions/findings/findings-triage.fixtures.ts b/ui/actions/findings/findings-triage.fixtures.ts new file mode 100644 index 0000000000..879564ed8b --- /dev/null +++ b/ui/actions/findings/findings-triage.fixtures.ts @@ -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; diff --git a/ui/actions/findings/findings-triage.options.ts b/ui/actions/findings/findings-triage.options.ts new file mode 100644 index 0000000000..1d0ad6314a --- /dev/null +++ b/ui/actions/findings/findings-triage.options.ts @@ -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 }), + }; +} diff --git a/ui/actions/findings/findings-triage.test.ts b/ui/actions/findings/findings-triage.test.ts new file mode 100644 index 0000000000..d494b64be5 --- /dev/null +++ b/ui/actions/findings/findings-triage.test.ts @@ -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", + }, + }, + }), + }), + ); + }); +}); diff --git a/ui/actions/findings/findings-triage.ts b/ui/actions/findings/findings-triage.ts new file mode 100644 index 0000000000..c08c7a1335 --- /dev/null +++ b/ui/actions/findings/findings-triage.ts @@ -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; +} => + 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) { + 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 { + 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; +} diff --git a/ui/actions/findings/findings.test.ts b/ui/actions/findings/findings.test.ts new file mode 100644 index 0000000000..efe04f119c --- /dev/null +++ b/ui/actions/findings/findings.test.ts @@ -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"); + }); +}); diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 242bd007a8..0635d273ce 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -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 = ( + 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; diff --git a/ui/actions/findings/index.ts b/ui/actions/findings/index.ts index d9fd5ae3b5..c4de6f1945 100644 --- a/ui/actions/findings/index.ts +++ b/ui/actions/findings/index.ts @@ -1,3 +1,4 @@ export * from "./findings"; export * from "./findings-by-resource"; export * from "./findings-by-resource.adapter"; +export * from "./findings-triage"; diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts index 5b571e2bba..c74c4a7e2a 100644 --- a/ui/actions/mute-rules/mute-rules.ts +++ b/ui/actions/mute-rules/mute-rules.ts @@ -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 diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index d8c5d75456..5f45b7815a 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -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({ diff --git a/ui/app/(prowler)/mutelist/mutelist-tabs.tsx b/ui/app/(prowler)/mutelist/mutelist-tabs.tsx index 9540c320d0..92a08f0f67 100644 --- a/ui/app/(prowler)/mutelist/mutelist-tabs.tsx +++ b/ui/app/(prowler)/mutelist/mutelist-tabs.tsx @@ -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 ( - - - Simple - - - - Advanced - + Simple + Advanced {simpleContent} diff --git a/ui/components/compliance/compliance-header/scan-selector.tsx b/ui/components/compliance/compliance-header/scan-selector.tsx index ae8f5fb928..09255f7761 100644 --- a/ui/components/compliance/compliance-header/scan-selector.tsx +++ b/ui/components/compliance/compliance-header/scan-selector.tsx @@ -60,11 +60,7 @@ export const ScanSelector = ({ {scans.map((scan) => ( - + ))} diff --git a/ui/components/findings/table/column-finding-groups.test.tsx b/ui/components/findings/table/column-finding-groups.test.tsx index eefbcb5a45..3daf96987c 100644 --- a/ui/components/findings/table/column-finding-groups.test.tsx +++ b/ui/components/findings/table/column-finding-groups.test.tsx @@ -19,6 +19,9 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/components/shadcn", () => ({ + Button: ({ children, ...props }: { children: ReactNode }) => ( + + ), Checkbox: ({ "aria-label": ariaLabel, onCheckedChange, @@ -35,6 +38,9 @@ vi.mock("@/components/shadcn", () => ({ {...props} /> ), + Textarea: (props: InputHTMLAttributes) => ( +