diff --git a/.changeset/heureka-change-severity-action.md b/.changeset/heureka-change-severity-action.md new file mode 100644 index 0000000000..c37e115439 --- /dev/null +++ b/.changeset/heureka-change-severity-action.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-app-heureka": patch +--- + +Add "Change Severity" action to vulnerability rows on the image details page. Users can now rescore a vulnerability's severity level by selecting a new severity from a dropdown, alongside the existing False Positive, Accept Risk, and Mitigate Manually actions. diff --git a/apps/heureka/src/components/Service/ImageDetails/ChangeSeverityModal/ChangeSeverityModal.test.tsx b/apps/heureka/src/components/Service/ImageDetails/ChangeSeverityModal/ChangeSeverityModal.test.tsx new file mode 100644 index 0000000000..cd19717064 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ChangeSeverityModal/ChangeSeverityModal.test.tsx @@ -0,0 +1,207 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { AuthProvider } from "@cloudoperators/greenhouse-auth-provider" +import { ChangeSeverityModal } from "./index" +import { RemediationTypeValues, SeverityValues } from "../../../../generated/graphql" + +// Mock DateTimePicker and Select so tests work without flatpickr/portal DOM interaction +vi.mock("@cloudoperators/juno-ui-components", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = await importActual() + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + DateTimePicker: ({ + label, + onChange, + required, + invalid, + errortext, + }: { + label: string + onChange: (dates: Date[]) => void + required?: boolean + invalid?: boolean + errortext?: string + }) => ( +
+ + onChange(e.target.value ? [new Date(e.target.value)] : [])} + /> + {invalid && errortext ? {errortext} : null} +
+ ), + Select: ({ + label, + onChange, + children, + required, + invalid, + errortext, + }: { + label: string + onChange: (value?: string | number | string[]) => void + children?: React.ReactNode + required?: boolean + invalid?: boolean + errortext?: string + }) => ( +
+ + + {invalid && errortext ? {errortext} : null} +
+ ), + SelectOption: ({ value, label }: { value: string; label: string }) => , + } +}) + +const mockAuth = { getSnapshot: () => ({ status: "anonymous" as const }) } + +const defaultProps = { + open: true, + onClose: vi.fn(), + onConfirm: vi.fn().mockResolvedValue(undefined), + vulnerability: "CVE-2024-1234", + severity: "High", + service: "my-service", + image: "my-image", +} + +const renderModal = (props: Partial & Record = {}) => + render( + + + + + + ) + +describe("ChangeSeverityModal", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("rendering", () => { + it("renders title and vulnerability details when open", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Change Severity" })).toBeInTheDocument() + expect(screen.getByText(/Vulnerability:/)).toBeInTheDocument() + expect(screen.getByText("CVE-2024-1234")).toBeInTheDocument() + expect(screen.getByText(/Current Severity:/)).toBeInTheDocument() + // Current severity label is shown in a dedicated line + expect(screen.getAllByText(/High/).length).toBeGreaterThan(0) + }) + + it("renders severity dropdown, user ID, expiration date, and description fields", () => { + renderModal() + expect(screen.getByLabelText(/New Severity/i)).toBeInTheDocument() + expect(screen.getByLabelText(/User ID/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Expiration Date/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Description/i)).toBeInTheDocument() + }) + + it("confirm button is disabled when required fields are empty", () => { + renderModal() + expect(screen.getByRole("button", { name: "Change Severity" })).toBeDisabled() + }) + }) + + describe("validation", () => { + it("shows error when severity is not selected and confirm is clicked", async () => { + const user = userEvent.setup() + renderModal() + // Fill all other fields except severity + await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") + fireEvent.change(screen.getByLabelText(/Expiration Date/i), { target: { value: "2026-12-31" } }) + await user.type(screen.getByPlaceholderText(/reason for changing/i), "Rescoring based on analysis") + // Manually click the button (it's still disabled without severity, so force click via fireEvent) + const btn = screen.getByRole("button", { name: "Change Severity" }) + expect(btn).toBeDisabled() + }) + + it("confirm button becomes enabled when all required fields are filled", async () => { + const user = userEvent.setup() + renderModal() + const btn = screen.getByRole("button", { name: "Change Severity" }) + expect(btn).toBeDisabled() + + fireEvent.change(screen.getByLabelText(/New Severity/i), { target: { value: SeverityValues.Medium } }) + await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") + fireEvent.change(screen.getByLabelText(/Expiration Date/i), { target: { value: "2026-12-31" } }) + await user.type(screen.getByPlaceholderText(/reason for changing/i), "Rescoring based on analysis") + + expect(btn).not.toBeDisabled() + }) + }) + + describe("submission", () => { + it("calls onConfirm with Rescore type and correct fields when form is valid", async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined) + const user = userEvent.setup() + renderModal({ onConfirm }) + + fireEvent.change(screen.getByLabelText(/New Severity/i), { target: { value: SeverityValues.Medium } }) + await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") + fireEvent.change(screen.getByLabelText(/Expiration Date/i), { target: { value: "2026-12-31" } }) + await user.type(screen.getByPlaceholderText(/reason for changing/i), "Rescoring based on analysis") + + await user.click(screen.getByRole("button", { name: "Change Severity" })) + + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + type: RemediationTypeValues.Rescore, + vulnerability: "CVE-2024-1234", + service: "my-service", + image: "my-image", + severity: SeverityValues.Medium, + remediatedBy: "user-123", + description: "Rescoring based on analysis", + }) + ) + }) + + it("shows API error message when onConfirm returns an error", async () => { + const onConfirm = vi.fn().mockResolvedValue({ error: "Rescore failed on server" }) + const user = userEvent.setup() + renderModal({ onConfirm }) + + fireEvent.change(screen.getByLabelText(/New Severity/i), { target: { value: SeverityValues.Low } }) + await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") + fireEvent.change(screen.getByLabelText(/Expiration Date/i), { target: { value: "2026-12-31" } }) + await user.type(screen.getByPlaceholderText(/reason for changing/i), "Rescoring based on analysis") + + await user.click(screen.getByRole("button", { name: "Change Severity" })) + + expect(await screen.findByText("Rescore failed on server")).toBeInTheDocument() + }) + + it("calls onClose when Cancel is clicked", async () => { + const onClose = vi.fn() + const user = userEvent.setup() + renderModal({ onClose }) + await user.click(screen.getByRole("button", { name: "Cancel" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/apps/heureka/src/components/Service/ImageDetails/ChangeSeverityModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ChangeSeverityModal/index.tsx new file mode 100644 index 0000000000..72ca0ed7de --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ChangeSeverityModal/index.tsx @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useRef, useEffect } from "react" +import { + Modal, + ModalFooter, + Button, + Stack, + Textarea, + TextInput, + Select, + SelectOption, + DateTimePicker, + Message, +} from "@cloudoperators/juno-ui-components" +import { RemediationInput, RemediationTypeValues, SeverityValues } from "../../../../generated/graphql" +import { useAuth } from "@cloudoperators/greenhouse-auth-provider" + +type ChangeSeverityModalProps = { + open: boolean + onClose: () => void + onConfirm: (input: RemediationInput) => Promise<{ error: string } | void> + vulnerability: string + severity?: string + service: string + image: string +} + +const EMPTY_FORM = { + description: "", + manualUserId: "", + newSeverity: "" as SeverityValues | "", + expirationDate: null as Date | null, +} +const EMPTY_ERRORS = { description: "", userId: "", newSeverity: "", expirationDate: "" } + +const SEVERITY_OPTIONS: { label: string; value: SeverityValues }[] = [ + { label: "Critical", value: SeverityValues.Critical }, + { label: "High", value: SeverityValues.High }, + { label: "Medium", value: SeverityValues.Medium }, + { label: "Low", value: SeverityValues.Low }, + { label: "None", value: SeverityValues.None }, +] + +export const ChangeSeverityModal: React.FC = ({ + open, + onClose, + onConfirm, + vulnerability, + severity, + service, + image, +}) => { + // useAuth() from @cloudoperators/greenhouse-auth-provider returns a discriminated union without exported types + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const auth = useAuth() as any + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + const authUserId = + auth.status === "authenticated" + ? (auth.userId as string | undefined) || (auth.userName as string | undefined) + : null + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ + const [form, setForm] = useState(EMPTY_FORM) + const [errors, setErrors] = useState(EMPTY_ERRORS) + const [isSubmitting, setIsSubmitting] = useState(false) + const [apiError, setApiError] = useState(null) + const isMountedRef = useRef(true) + + const manualUserIdTrimmed = form.manualUserId.trim() + const remediatedBy = authUserId ?? (manualUserIdTrimmed || undefined) + const isUserIdValid = !!remediatedBy + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + useEffect(() => { + if (!open) { + setForm(EMPTY_FORM) + setErrors(EMPTY_ERRORS) + setApiError(null) + } + }, [open]) + + const descriptionTrimmed = form.description.trim() + + const handleConfirm = async () => { + let hasError = false + if (!form.newSeverity) { + setErrors((prev) => ({ ...prev, newSeverity: "New severity is required" })) + hasError = true + } + if (!remediatedBy) { + setErrors((prev) => ({ ...prev, userId: "User ID is required" })) + hasError = true + } + if (!descriptionTrimmed) { + setErrors((prev) => ({ ...prev, description: "Description is required" })) + hasError = true + } + if (!form.expirationDate) { + setErrors((prev) => ({ ...prev, expirationDate: "Expiration date is required" })) + hasError = true + } + if (hasError) return + + setErrors(EMPTY_ERRORS) + setIsSubmitting(true) + try { + const input: RemediationInput = { + type: RemediationTypeValues.Rescore, + vulnerability, + service, + image, + description: descriptionTrimmed, + ...(remediatedBy && { remediatedBy }), + severity: form.newSeverity as SeverityValues, + expirationDate: form.expirationDate!.toISOString(), + } + const result = await onConfirm(input) + if (!isMountedRef.current) return + if (result?.error) { + setApiError(result.error) + } else { + setForm(EMPTY_FORM) + onClose() + } + } catch (error) { + if (!isMountedRef.current) return + const message = error instanceof Error ? error.message : "Failed to change severity" + setApiError(message) + } finally { + if (isMountedRef.current) setIsSubmitting(false) + } + } + + const handleClose = () => { + setForm(EMPTY_FORM) + setErrors(EMPTY_ERRORS) + setApiError(null) + onClose() + } + + const isConfirmDisabled = + isSubmitting || !form.newSeverity || !isUserIdValid || !descriptionTrimmed || !form.expirationDate + + return ( + + +