diff --git a/.changeset/real-ears-walk.md b/.changeset/real-ears-walk.md new file mode 100644 index 0000000000..328dc6ccda --- /dev/null +++ b/.changeset/real-ears-walk.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-app-heureka": patch +--- + +Wire Jira ticket to dedicated url field in createRemediation for risk acceptance, replacing the description-prefix workaround. Surface the ticket in a new "Source Ticket" column in the remediations history panel with improved date formatting and empty-value handling. diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/RemediationHistoryPanel.test.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/RemediationHistoryPanel.test.tsx index 28843a5043..a8c8d739a5 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/RemediationHistoryPanel.test.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/RemediationHistoryPanel.test.tsx @@ -9,6 +9,45 @@ import { createMemoryHistory, createRootRoute, createRoute, Outlet, RouterProvid import { PortalProvider } from "@cloudoperators/juno-ui-components" import { RemediationHistoryPanel } from "./index" import { getTestRouter } from "../../../../../mocks/getTestRouter" +import { RemediationTypeValues } from "../../../../../generated/graphql" + +vi.mock("../../../../../api/fetchRemediations", () => ({ + fetchRemediations: vi.fn(), +})) + +import { fetchRemediations } from "../../../../../api/fetchRemediations" + +const makeMockRemediationsPromise = ( + edges: Array<{ + id: string + type: RemediationTypeValues | null + description: string | null + url: string | null + remediatedBy: string | null + remediationDate: string | null + expirationDate: string | null + }> +): Promise => + Promise.resolve({ + data: { + Remediations: { + edges: edges.map((node) => ({ + node: { + ...node, + service: "my-service", + image: "my-image", + vulnerability: "CVE-2024-1234", + }, + })), + totalCount: edges.length, + }, + }, + loading: false, + networkStatus: 7, + partial: false, + error: undefined, + dataState: "complete" as const, + }) const renderPanel = (vulnerability: string | null = null) => { const rootRoute = createRootRoute({ @@ -36,8 +75,93 @@ const renderPanel = (vulnerability: string | null = null) => { } describe("RemediationHistoryPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it("renders without crashing when vulnerability is null (panel closed)", () => { renderPanel(null) expect(screen.queryByText("Revert False Positive")).not.toBeInTheDocument() }) + + it("renders Source Ticket column header and cell value when panel is open", async () => { + vi.mocked(fetchRemediations).mockReturnValue( + makeMockRemediationsPromise([ + { + id: "rem-0", + type: RemediationTypeValues.RiskAccepted, + description: "Some reason", + url: "JIRA-1", + remediatedBy: "user-1", + remediationDate: "2025-01-01T00:00:00Z", + expirationDate: "2026-01-01T00:00:00Z", + }, + ]) + ) + renderPanel("CVE-2024-1234") + // Wait for data row to confirm Suspense resolved, then check header + expect(await screen.findByText("JIRA-1")).toBeInTheDocument() + expect(screen.getByText("Source Ticket")).toBeInTheDocument() + }) + + it("shows url value in Source Ticket cell for risk_accepted remediations", async () => { + vi.mocked(fetchRemediations).mockReturnValue( + makeMockRemediationsPromise([ + { + id: "rem-1", + type: RemediationTypeValues.RiskAccepted, + description: "Accepted for now", + url: "JIRA-9999", + remediatedBy: "user-123", + remediationDate: "2025-01-15T00:00:00Z", + expirationDate: "2026-01-15T00:00:00Z", + }, + ]) + ) + renderPanel("CVE-2024-1234") + expect(await screen.findByText("JIRA-9999")).toBeInTheDocument() + }) + + it("shows -- when risk_accepted remediation has no url", async () => { + vi.mocked(fetchRemediations).mockReturnValue( + makeMockRemediationsPromise([ + { + id: "rem-2", + type: RemediationTypeValues.RiskAccepted, + description: "No ticket", + url: null, + remediatedBy: "user-123", + remediationDate: "2025-01-15T00:00:00Z", + expirationDate: "2026-01-15T00:00:00Z", + }, + ]) + ) + renderPanel("CVE-2024-1234") + // description and remediatedBy are non-null, dates are non-null → only url cell shows -- + expect(await screen.findByText("--")).toBeInTheDocument() + }) + + it("shows empty Source Ticket cell for non-risk_accepted remediations", async () => { + vi.mocked(fetchRemediations).mockReturnValue( + makeMockRemediationsPromise([ + { + id: "rem-3", + type: RemediationTypeValues.Mitigation, + description: "Applied patch", + url: "JIRA-0000", + remediatedBy: "user-456", + remediationDate: "2025-03-01T00:00:00Z", + expirationDate: "2026-03-01T00:00:00Z", + }, + ]) + ) + renderPanel("CVE-2024-1234") + expect(await screen.findByText("Applied patch")).toBeInTheDocument() + // url is set on the node but type is not risk_accepted → Source Ticket cell must be empty + expect(screen.queryByText("JIRA-0000")).not.toBeInTheDocument() + // Verify the Source Ticket cell (6th gridcell in the data row) is empty + const dataCells = screen.getAllByRole("gridcell") + // gridcells: type(0), expirationDate(1), remediationDate(2), remediatedBy(3), description(4), sourceTicket(5), actions(6) + expect(dataCells[5].textContent).toBe("") + }) }) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index 7f4a2663bf..670b61e4a5 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -41,15 +41,16 @@ type RemediationHistoryPanelProps = { refreshKey?: number } -const COLUMN_SPAN = 6 +const COLUMN_SPAN = 7 function formatDateTime(value: string | null): string { - if (!value) return "—" + if (!value) return "--" try { const d = new Date(value) - return Number.isNaN(d.getTime()) - ? value - : d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" }) + if (Number.isNaN(d.getTime())) return value + const dayMonth = d.toLocaleDateString("en-GB", { month: "short", day: "2-digit" }) + const year = String(d.getFullYear()).padStart(4, "0") + return `${dayMonth} ${year}` } catch { return value } @@ -87,11 +88,12 @@ const RemediationHistoryTable = ({ <> {remediatedVulnerabilities.map((r: RemediatedVulnerability) => ( - {r.type ?? "—"} + {r.type ?? "--"} {formatDateTime(r.expirationDate)} {formatDateTime(r.remediationDate)} - {r.remediatedBy ?? "—"} - {r.description ?? "—"} + {r.remediatedBy ?? "--"} + {r.description ?? "--"} + {r.type === "risk_accepted" ? (r.url ?? "--") : ""} e.stopPropagation()}> {revertingId === r.remediationId ? ( @@ -225,13 +227,14 @@ export const RemediationHistoryPanel = ({ fallbackRender={getErrorDataRowComponent({ colspan: COLUMN_SPAN })} resetKeys={[remediationsPromise]} > - + Type Expiration Date Remediation Date Remediated By Description + Source Ticket }> diff --git a/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/RiskAcceptanceModal.test.tsx b/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/RiskAcceptanceModal.test.tsx index 936fa13f9c..3e3f56eaf2 100644 --- a/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/RiskAcceptanceModal.test.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/RiskAcceptanceModal.test.tsx @@ -112,6 +112,7 @@ describe("RiskAcceptanceModal", () => { renderModal({ onConfirm }) await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") + await user.type(screen.getByPlaceholderText("e.g. JIRA-1234"), "JIRA-9999") await user.type(screen.getByPlaceholderText(/Add a description explaining the reason/i), "Some reason") fireEvent.change(screen.getByLabelText("Expiration Date"), { target: { value: "2026-12-31" } }) await user.click(screen.getByRole("button", { name: "Accept Risk" })) @@ -142,8 +143,12 @@ describe("RiskAcceptanceModal", () => { await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") expect(confirmButton).toBeDisabled() - // All three required fields → button enabled + // Description + user ID + expiration (still missing Jira ticket) fireEvent.change(screen.getByLabelText("Expiration Date"), { target: { value: "2026-12-31" } }) + expect(confirmButton).toBeDisabled() + + // All four required fields → button enabled + await user.type(screen.getByPlaceholderText("e.g. JIRA-1234"), "JIRA-9999") expect(confirmButton).not.toBeDisabled() }) @@ -176,6 +181,7 @@ describe("RiskAcceptanceModal", () => { renderModal({ onConfirm }) await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") + await user.type(screen.getByPlaceholderText("e.g. JIRA-1234"), "JIRA-9999") await user.type(screen.getByPlaceholderText(/Add a description explaining the reason/i), "Low exposure, accepted") fireEvent.change(screen.getByLabelText("Expiration Date"), { target: { value: "2026-12-31" } }) @@ -188,12 +194,13 @@ describe("RiskAcceptanceModal", () => { service: "my-service", image: "my-image", remediatedBy: "user-123", + url: "JIRA-9999", description: "Low exposure, accepted", }) ) }) - it("includes source ticket prefix in description when source ticket is provided", async () => { + it("sends source ticket as url field (not embedded in description) when provided", async () => { const onConfirm = vi.fn().mockResolvedValue(undefined) const user = userEvent.setup() renderModal({ onConfirm }) @@ -207,24 +214,7 @@ describe("RiskAcceptanceModal", () => { expect(onConfirm).toHaveBeenCalledWith( expect.objectContaining({ - description: "Source Ticket: JIRA-9999\n\nAccepted", - }) - ) - }) - - it("does not include source ticket prefix when source ticket field is empty", async () => { - const onConfirm = vi.fn().mockResolvedValue(undefined) - const user = userEvent.setup() - renderModal({ onConfirm }) - - await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") - await user.type(screen.getByPlaceholderText(/Add a description explaining the reason/i), "Accepted") - fireEvent.change(screen.getByLabelText("Expiration Date"), { target: { value: "2026-12-31" } }) - - await user.click(screen.getByRole("button", { name: "Accept Risk" })) - - expect(onConfirm).toHaveBeenCalledWith( - expect.objectContaining({ + url: "JIRA-9999", description: "Accepted", }) ) @@ -237,6 +227,7 @@ describe("RiskAcceptanceModal", () => { renderModal({ onConfirm, onClose }) await user.type(screen.getByPlaceholderText(/Enter your user ID/i), "user-123") + await user.type(screen.getByPlaceholderText("e.g. JIRA-1234"), "JIRA-9999") await user.type(screen.getByPlaceholderText(/Add a description explaining the reason/i), "Some reason") fireEvent.change(screen.getByLabelText("Expiration Date"), { target: { value: "2026-12-31" } }) await user.click(screen.getByRole("button", { name: "Accept Risk" })) diff --git a/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/index.tsx index ab7e56ee56..d4e98084b6 100644 --- a/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/RiskAcceptanceModal/index.tsx @@ -55,6 +55,7 @@ export const RiskAcceptanceModal: React.FC = ({ const [isSubmitting, setIsSubmitting] = useState(false) const [descriptionError, setDescriptionError] = useState("") const [userIdError, setUserIdError] = useState("") + const [sourceTicketError, setSourceTicketError] = useState("") const [expirationDateError, setExpirationDateError] = useState("") const [apiError, setApiError] = useState(null) const isMountedRef = useRef(true) @@ -78,6 +79,7 @@ export const RiskAcceptanceModal: React.FC = ({ setExpirationDate(null) setDescriptionError("") setUserIdError("") + setSourceTicketError("") setExpirationDateError("") setApiError(null) } @@ -86,13 +88,6 @@ export const RiskAcceptanceModal: React.FC = ({ const descriptionTrimmed = description.trim() const sourceTicketTrimmed = sourceTicket.trim() - const buildDescription = () => { - if (sourceTicketTrimmed) { - return `Source Ticket: ${sourceTicketTrimmed}\n\n${descriptionTrimmed}` - } - return descriptionTrimmed - } - const handleConfirm = async () => { if (!descriptionTrimmed) { setDescriptionError("Description is required") @@ -102,6 +97,10 @@ export const RiskAcceptanceModal: React.FC = ({ setUserIdError("User ID is required") return } + if (!sourceTicketTrimmed) { + setSourceTicketError("Jira ticket is required") + return + } if (!expirationDate) { setExpirationDateError("Expiration date is required") return @@ -109,6 +108,7 @@ export const RiskAcceptanceModal: React.FC = ({ setDescriptionError("") setUserIdError("") + setSourceTicketError("") setExpirationDateError("") setIsSubmitting(true) try { @@ -118,10 +118,11 @@ export const RiskAcceptanceModal: React.FC = ({ vulnerability, service, image, - description: buildDescription(), + description: descriptionTrimmed, ...(remediatedBy && { remediatedBy }), ...(severityValue !== undefined && { severity: severityValue }), expirationDate: expirationDate.toISOString(), + ...(sourceTicketTrimmed && { url: sourceTicketTrimmed }), } const result = await onConfirm(input) if (result?.error) { @@ -173,7 +174,9 @@ export const RiskAcceptanceModal: React.FC = ({ onClick={handleConfirm} label={CONFIRM_LABEL} variant="primary" - disabled={isSubmitting || !descriptionTrimmed || !isUserIdValid || !expirationDate} + disabled={ + isSubmitting || !descriptionTrimmed || !isUserIdValid || !sourceTicketTrimmed || !expirationDate + } /> @@ -210,9 +213,15 @@ export const RiskAcceptanceModal: React.FC = ({ setSourceTicket(e.target.value)} + onChange={(e) => { + setSourceTicket(e.target.value) + if (sourceTicketError) setSourceTicketError("") + }} placeholder="e.g. JIRA-1234" - helptext="Optional. Reference ticket for this risk acceptance decision." + required + invalid={!!sourceTicketError} + errortext={sourceTicketError} + helptext="Reference ticket for this risk acceptance decision." />
diff --git a/apps/heureka/src/components/Services/utils.ts b/apps/heureka/src/components/Services/utils.ts index 278623ba88..587c1bb883 100644 --- a/apps/heureka/src/components/Services/utils.ts +++ b/apps/heureka/src/components/Services/utils.ts @@ -278,6 +278,7 @@ export type RemediatedVulnerability = { remediationDate: string | null remediatedBy: string | null expirationDate: string | null + url: string | null } type NormalizedServiceImageVulnerabilities = { @@ -622,8 +623,9 @@ export const getNormalizedRemediationsResponse = ( vulnerability: node.vulnerability || null, vulnerabilityId: null, remediationDate: node.remediationDate != null ? String(node.remediationDate) : null, - remediatedBy: node.remediatedBy ?? null, + remediatedBy: node.remediatedBy?.trim() || null, expirationDate: node.expirationDate != null ? String(node.expirationDate) : null, + url: node.url?.trim() || null, } }) diff --git a/apps/heureka/src/generated/graphql.ts b/apps/heureka/src/generated/graphql.ts index fce934d7a3..46814ae1c8 100644 --- a/apps/heureka/src/generated/graphql.ts +++ b/apps/heureka/src/generated/graphql.ts @@ -1296,6 +1296,7 @@ export type Remediation = Node & { serviceId?: Maybe severity?: Maybe type?: Maybe + url?: Maybe vulnerability?: Maybe vulnerabilityId?: Maybe } @@ -1320,6 +1321,7 @@ export type RemediationFilter = { severity?: InputMaybe>> state?: InputMaybe> type?: InputMaybe>> + url?: InputMaybe>> vulnerability?: InputMaybe>> } @@ -1332,6 +1334,7 @@ export type RemediationInput = { service?: InputMaybe severity?: InputMaybe type?: InputMaybe + url?: InputMaybe vulnerability?: InputMaybe } @@ -1795,6 +1798,7 @@ export type CreateRemediationMutation = { serviceId?: string | null severity?: SeverityValues | null type?: RemediationTypeValues | null + url?: string | null vulnerability?: string | null vulnerabilityId?: string | null } @@ -1828,6 +1832,7 @@ export type GetRemediationsQuery = { expirationDate?: any | null remediationDate?: any | null remediatedBy?: string | null + url?: string | null } } | null> | null } | null @@ -2189,6 +2194,7 @@ export const CreateRemediationDocument = gql` serviceId severity type + url vulnerability vulnerabilityId } @@ -2213,6 +2219,7 @@ export const GetRemediationsDocument = gql` expirationDate remediationDate remediatedBy + url } } totalCount diff --git a/apps/heureka/src/graphql/Remediations/createRemediation.graphql b/apps/heureka/src/graphql/Remediations/createRemediation.graphql index 4e32a790d8..206039bb61 100644 --- a/apps/heureka/src/graphql/Remediations/createRemediation.graphql +++ b/apps/heureka/src/graphql/Remediations/createRemediation.graphql @@ -14,6 +14,7 @@ mutation CreateRemediation($input: RemediationInput!) { serviceId severity type + url vulnerability vulnerabilityId } diff --git a/apps/heureka/src/graphql/Remediations/getRemediations.graphql b/apps/heureka/src/graphql/Remediations/getRemediations.graphql index 023be7d7ab..ade42a7a4c 100644 --- a/apps/heureka/src/graphql/Remediations/getRemediations.graphql +++ b/apps/heureka/src/graphql/Remediations/getRemediations.graphql @@ -14,6 +14,7 @@ query GetRemediations($filter: RemediationFilter) { expirationDate remediationDate remediatedBy + url } } totalCount