Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/real-ears-walk.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> =>
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({
Expand Down Expand Up @@ -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("")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -87,11 +88,12 @@ const RemediationHistoryTable = ({
<>
{remediatedVulnerabilities.map((r: RemediatedVulnerability) => (
<DataGridRow key={r.remediationId}>
<DataGridCell className="whitespace-nowrap">{r.type ?? ""}</DataGridCell>
<DataGridCell className="whitespace-nowrap">{r.type ?? "--"}</DataGridCell>
<DataGridCell className="whitespace-nowrap">{formatDateTime(r.expirationDate)}</DataGridCell>
<DataGridCell className="whitespace-nowrap">{formatDateTime(r.remediationDate)}</DataGridCell>
Comment thread
hodanoori marked this conversation as resolved.
<DataGridCell>{r.remediatedBy ?? "—"}</DataGridCell>
<DataGridCell>{r.description ?? "—"}</DataGridCell>
<DataGridCell>{r.remediatedBy ?? "--"}</DataGridCell>
<DataGridCell className="min-w-0">{r.description ?? "--"}</DataGridCell>
<DataGridCell className="min-w-0">{r.type === "risk_accepted" ? (r.url ?? "--") : ""}</DataGridCell>
<DataGridCell className="cursor-default interactive" onClick={(e) => e.stopPropagation()}>
{revertingId === r.remediationId ? (
<Spinner variant="primary" size="small" className="ml-auto" />
Expand Down Expand Up @@ -225,13 +227,14 @@ export const RemediationHistoryPanel = ({
fallbackRender={getErrorDataRowComponent({ colspan: COLUMN_SPAN })}
resetKeys={[remediationsPromise]}
>
<DataGrid columns={COLUMN_SPAN} minContentColumns={[0, 1, 2, 3, 5]} cellVerticalAlignment="top">
<DataGrid columns={COLUMN_SPAN} minContentColumns={[0, 1, 2, 3, 6]} cellVerticalAlignment="top">
<DataGridRow>
<DataGridHeadCell>Type</DataGridHeadCell>
<DataGridHeadCell>Expiration Date</DataGridHeadCell>
<DataGridHeadCell>Remediation Date</DataGridHeadCell>
<DataGridHeadCell>Remediated By</DataGridHeadCell>
<DataGridHeadCell>Description</DataGridHeadCell>
<DataGridHeadCell>Source Ticket</DataGridHeadCell>
<DataGridHeadCell />
Comment thread
hodanoori marked this conversation as resolved.
</DataGridRow>
<Suspense fallback={<LoadingDataRow colSpan={COLUMN_SPAN} />}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }))
Expand Down Expand Up @@ -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()
})

Expand Down Expand Up @@ -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" } })

Expand All @@ -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 })
Expand All @@ -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",
})
)
Expand All @@ -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" }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const RiskAcceptanceModal: React.FC<RiskAcceptanceModalProps> = ({
const [isSubmitting, setIsSubmitting] = useState(false)
const [descriptionError, setDescriptionError] = useState<string>("")
const [userIdError, setUserIdError] = useState<string>("")
const [sourceTicketError, setSourceTicketError] = useState<string>("")
const [expirationDateError, setExpirationDateError] = useState<string>("")
const [apiError, setApiError] = useState<string | null>(null)
const isMountedRef = useRef(true)
Expand All @@ -78,6 +79,7 @@ export const RiskAcceptanceModal: React.FC<RiskAcceptanceModalProps> = ({
setExpirationDate(null)
setDescriptionError("")
setUserIdError("")
setSourceTicketError("")
setExpirationDateError("")
setApiError(null)
}
Expand All @@ -86,13 +88,6 @@ export const RiskAcceptanceModal: React.FC<RiskAcceptanceModalProps> = ({
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")
Expand All @@ -102,13 +97,18 @@ export const RiskAcceptanceModal: React.FC<RiskAcceptanceModalProps> = ({
setUserIdError("User ID is required")
return
}
if (!sourceTicketTrimmed) {
setSourceTicketError("Jira ticket is required")
return
}
if (!expirationDate) {
setExpirationDateError("Expiration date is required")
return
}

setDescriptionError("")
setUserIdError("")
setSourceTicketError("")
setExpirationDateError("")
setIsSubmitting(true)
try {
Expand All @@ -118,10 +118,11 @@ export const RiskAcceptanceModal: React.FC<RiskAcceptanceModalProps> = ({
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) {
Expand Down Expand Up @@ -173,7 +174,9 @@ export const RiskAcceptanceModal: React.FC<RiskAcceptanceModalProps> = ({
onClick={handleConfirm}
label={CONFIRM_LABEL}
variant="primary"
disabled={isSubmitting || !descriptionTrimmed || !isUserIdValid || !expirationDate}
disabled={
isSubmitting || !descriptionTrimmed || !isUserIdValid || !sourceTicketTrimmed || !expirationDate
}
/>
</Stack>
</ModalFooter>
Expand Down Expand Up @@ -210,9 +213,15 @@ export const RiskAcceptanceModal: React.FC<RiskAcceptanceModalProps> = ({
<TextInput
label="Jira Ticket / Risk Acceptance Source Ticket"
value={sourceTicket}
onChange={(e) => 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."
/>
</div>
<div>
Expand Down
4 changes: 3 additions & 1 deletion apps/heureka/src/components/Services/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export type RemediatedVulnerability = {
remediationDate: string | null
remediatedBy: string | null
expirationDate: string | null
url: string | null
}

type NormalizedServiceImageVulnerabilities = {
Expand Down Expand Up @@ -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,
}
})

Expand Down
Loading
Loading