From 3672d19c6ae363bd1b9671141bd9c5ae75c23668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Fri, 13 Mar 2026 11:45:10 +0100 Subject: [PATCH] feat(mutelisting): add mute button inside finding detailed view (#10303) --- ui/CHANGELOG.md | 4 + .../attack-paths/(workflow)/layout.tsx | 7 + .../findings/table/finding-detail.test.tsx | 295 ++++++++++++++++++ .../findings/table/finding-detail.tsx | 98 ++++-- 4 files changed, 375 insertions(+), 29 deletions(-) create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/layout.tsx create mode 100644 ui/components/findings/table/finding-detail.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 95ddfc9bed..0d3931aea1 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.20.0] (Prowler v5.20.0) +### 🚀 Added + +- Mute button in the finding detailed view, allowing users to mute findings directly without going back to the table [(#10303)](https://github.com/prowler-cloud/prowler/pull/10303) + ### 🔄 Changed - Attack Paths: Improved error handling for server errors (5xx) and network failures with user-friendly messages instead of raw internal errors and layout changes [(#10249)](https://github.com/prowler-cloud/prowler/pull/10249) diff --git a/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx new file mode 100644 index 0000000000..fb9ef2f902 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx @@ -0,0 +1,7 @@ +export default function WorkflowLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/ui/components/findings/table/finding-detail.test.tsx b/ui/components/findings/table/finding-detail.test.tsx new file mode 100644 index 0000000000..1d01919326 --- /dev/null +++ b/ui/components/findings/table/finding-detail.test.tsx @@ -0,0 +1,295 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { FindingProps } from "@/types"; + +import { FindingDetail } from "./finding-detail"; + +// Mock next/navigation +const mockRefresh = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: mockRefresh }), + usePathname: () => "/findings", + useSearchParams: () => new URLSearchParams(), +})); + +// Mock @/components/shadcn to avoid next-auth import chain +vi.mock("@/components/shadcn", () => { + const Slot = ({ children }: { children: React.ReactNode }) => <>{children}; + return { + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes & { + variant?: string; + size?: string; + }) => , + Drawer: ({ children }: { children: React.ReactNode }) => <>{children}, + DrawerClose: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + DrawerContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + DrawerDescription: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + DrawerHeader: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + DrawerTitle: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + DrawerTrigger: Slot, + InfoField: ({ + children, + label, + }: { + children: React.ReactNode; + label: string; + variant?: string; + }) => ( +
+ {label} + {children} +
+ ), + Tabs: ({ children }: { children: React.ReactNode }) => <>{children}, + TabsContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + TabsList: ({ children }: { children: React.ReactNode }) => <>{children}, + TabsTrigger: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + TooltipTrigger: Slot, + }; +}); + +vi.mock("@/components/ui/code-snippet/code-snippet", () => ({ + CodeSnippet: ({ value }: { value: string }) => {value}, +})); + +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock("@/components/ui/entities", () => ({ + EntityInfo: () =>
, +})); + +vi.mock("@/components/ui/entities/date-with-time", () => ({ + DateWithTime: ({ dateTime }: { dateTime: string }) => {dateTime}, +})); + +vi.mock("@/components/ui/table/severity-badge", () => ({ + SeverityBadge: ({ severity }: { severity: string }) => ( + {severity} + ), +})); + +vi.mock("@/components/ui/table/status-finding-badge", () => ({ + FindingStatus: {}, + StatusFindingBadge: ({ status }: { status: string }) => {status}, +})); + +vi.mock("@/lib/iac-utils", () => ({ + buildGitFileUrl: () => null, + extractLineRangeFromUid: () => null, +})); + +vi.mock("@/lib/utils", () => ({ + cn: (...args: string[]) => args.filter(Boolean).join(" "), +})); + +// Mock child components that are not under test +vi.mock("../mute-findings-modal", () => ({ + MuteFindingsModal: ({ + isOpen, + findingIds, + }: { + isOpen: boolean; + findingIds: string[]; + }) => + isOpen ? ( +
Muting {findingIds.length} finding(s)
+ ) : null, +})); + +vi.mock("../muted", () => ({ + Muted: ({ isMuted }: { isMuted: boolean }) => + isMuted ? Muted : null, +})); + +vi.mock("./delta-indicator", () => ({ + DeltaIndicator: () => null, +})); + +vi.mock("react-markdown", () => ({ + default: ({ children }: { children: string }) => {children}, +})); + +const baseFinding: FindingProps = { + type: "findings", + id: "finding-123", + attributes: { + uid: "uid-123", + delta: null, + status: "FAIL", + status_extended: "S3 bucket is publicly accessible", + severity: "high", + check_id: "s3_bucket_public_access", + muted: false, + check_metadata: { + risk: "Public access risk", + notes: "", + checkid: "s3_bucket_public_access", + provider: "aws", + severity: "high", + checktype: [], + dependson: [], + relatedto: [], + categories: ["security"], + checktitle: "S3 Bucket Public Access Check", + compliance: null, + relatedurl: "", + description: "Checks if S3 buckets are publicly accessible", + remediation: { + code: { cli: "", other: "", nativeiac: "", terraform: "" }, + recommendation: { url: "", text: "" }, + }, + servicename: "s3", + checkaliases: [], + resourcetype: "AwsS3Bucket", + subservicename: "", + resourceidtemplate: "", + }, + raw_result: null, + inserted_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + first_seen_at: "2024-01-01T00:00:00Z", + }, + relationships: { + resources: { data: [{ type: "resources", id: "res-1" }] }, + scan: { + data: { type: "scans", id: "scan-1" }, + attributes: { + name: "Daily Scan", + trigger: "scheduled", + state: "completed", + unique_resource_count: 50, + progress: 100, + scanner_args: { checks_to_execute: [] }, + duration: 120, + started_at: "2024-01-01T00:00:00Z", + inserted_at: "2024-01-01T00:00:00Z", + completed_at: "2024-01-01T00:02:00Z", + scheduled_at: null, + next_scan_at: "2024-01-02T00:00:00Z", + }, + }, + resource: { + data: [{ type: "resources", id: "res-1" }], + id: "res-1", + attributes: { + uid: "arn:aws:s3:::my-bucket", + name: "my-bucket", + region: "us-east-1", + service: "s3", + tags: {}, + type: "AwsS3Bucket", + inserted_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + details: null, + partition: "aws", + }, + relationships: { + provider: { data: { type: "providers", id: "prov-1" } }, + findings: { + meta: { count: 1 }, + data: [{ type: "findings", id: "finding-123" }], + }, + }, + links: { self: "/resources/res-1" }, + }, + provider: { + data: { type: "providers", id: "prov-1" }, + attributes: { + provider: "aws", + uid: "123456789012", + alias: "my-account", + connection: { + connected: true, + last_checked_at: "2024-01-01T00:00:00Z", + }, + inserted_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + relationships: { + secret: { data: { type: "provider-secrets", id: "secret-1" } }, + }, + links: { self: "/providers/prov-1" }, + }, + }, + links: { self: "/findings/finding-123" }, +}; + +describe("FindingDetail", () => { + it("shows the Mute button for non-muted findings", () => { + render(); + + expect(screen.getByRole("button", { name: /mute/i })).toBeInTheDocument(); + }); + + it("hides the Mute button for muted findings", () => { + const mutedFinding: FindingProps = { + ...baseFinding, + attributes: { ...baseFinding.attributes, muted: true }, + }; + + render(); + + expect(screen.queryByRole("button", { name: /mute/i })).toBeNull(); + }); + + it("opens the mute modal when clicking the Mute button", async () => { + const user = userEvent.setup(); + + render(); + + expect(screen.queryByTestId("mute-modal")).toBeNull(); + + await user.click(screen.getByRole("button", { name: /mute/i })); + + expect(screen.getByTestId("mute-modal")).toBeInTheDocument(); + }); + + it("does not render the mute modal for muted findings", () => { + const mutedFinding: FindingProps = { + ...baseFinding, + attributes: { ...baseFinding.attributes, muted: true }, + }; + + render(); + + expect(screen.queryByTestId("mute-modal")).toBeNull(); + }); + + it("shows the muted badge for muted findings", () => { + const mutedFinding: FindingProps = { + ...baseFinding, + attributes: { ...baseFinding.attributes, muted: true }, + }; + + render(); + + expect(screen.getByTestId("muted-badge")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index ea6b27f653..195dadc042 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -1,11 +1,12 @@ "use client"; -import { ExternalLink, Link, X } from "lucide-react"; -import { usePathname, useSearchParams } from "next/navigation"; -import type { ReactNode } from "react"; +import { ExternalLink, Link, VolumeX, X } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useState } from "react"; import ReactMarkdown from "react-markdown"; import { + Button, Drawer, DrawerClose, DrawerContent, @@ -35,6 +36,7 @@ import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils"; import { cn } from "@/lib/utils"; import { FindingProps, ProviderType } from "@/types"; +import { MuteFindingsModal } from "../mute-findings-modal"; import { Muted } from "../muted"; import { DeltaIndicator } from "./delta-indicator"; @@ -85,8 +87,10 @@ export const FindingDetail = ({ const resource = finding.relationships?.resource?.attributes; const scan = finding.relationships?.scan?.attributes; const providerDetails = finding.relationships?.provider?.attributes; + const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const copyFindingUrl = () => { const params = new URLSearchParams(searchParams.toString()); @@ -106,6 +110,21 @@ export const FindingDetail = ({ ) : null; + const handleMuteComplete = () => { + setIsMuteModalOpen(false); + onOpenChange?.(false); + router.refresh(); + }; + + const muteModal = !attributes.muted && ( + + ); + const content = (
{/* Header */} @@ -154,11 +173,24 @@ export const FindingDetail = ({ {/* Tabs */} - - General - Resources - Scans - +
+ + General + Resources + Scans + + + {!attributes.muted && ( + + )} +
{/* General Tab */} @@ -444,29 +476,37 @@ export const FindingDetail = ({ // If no trigger, render content directly (inline mode) if (!trigger) { - return content; + return ( + <> + {muteModal} + {content} + + ); } - // With trigger, wrap in Drawer + // With trigger, wrap in Drawer — modal rendered outside to avoid nested overlay issues return ( - - {trigger} - - - Finding Details - View the finding details - - - - Close - - {content} - - + <> + {muteModal} + + {trigger} + + + Finding Details + View the finding details + + + + Close + + {content} + + + ); };