Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
6 changes: 6 additions & 0 deletions .changeset/real-ears-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudoperators/juno-app-heureka": patch
"@cloudoperators/juno-app-greenhouse": 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>
Comment thread
hodanoori marked this conversation as resolved.
Outdated
<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 @@ -193,7 +193,7 @@ describe("RiskAcceptanceModal", () => {
)
})

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,12 +207,13 @@ describe("RiskAcceptanceModal", () => {

expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
description: "Source Ticket: JIRA-9999\n\nAccepted",
url: "JIRA-9999",
description: "Accepted",
})
)
})

it("does not include source ticket prefix when source ticket field is empty", async () => {
it("omits url field when source ticket field is empty", async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
renderModal({ onConfirm })
Expand All @@ -223,11 +224,9 @@ describe("RiskAcceptanceModal", () => {

await user.click(screen.getByRole("button", { name: "Accept Risk" }))

expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
description: "Accepted",
})
)
const call = onConfirm.mock.calls[0][0]
expect(call).not.toHaveProperty("url")
expect(call).toMatchObject({ description: "Accepted" })
})

it("clears error message when Cancel is clicked after a server error", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,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 @@ -118,10 +111,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 }),
}
Comment thread
hodanoori marked this conversation as resolved.
const result = await onConfirm(input)
if (result?.error) {
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
7 changes: 7 additions & 0 deletions apps/heureka/src/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,7 @@ export type Remediation = Node & {
serviceId?: Maybe<Scalars["ID"]["output"]>
severity?: Maybe<SeverityValues>
type?: Maybe<RemediationTypeValues>
url?: Maybe<Scalars["String"]["output"]>
vulnerability?: Maybe<Scalars["String"]["output"]>
vulnerabilityId?: Maybe<Scalars["ID"]["output"]>
}
Expand All @@ -1320,6 +1321,7 @@ export type RemediationFilter = {
severity?: InputMaybe<Array<InputMaybe<SeverityValues>>>
state?: InputMaybe<Array<StateFilter>>
type?: InputMaybe<Array<InputMaybe<RemediationTypeValues>>>
url?: InputMaybe<Array<InputMaybe<Scalars["String"]["input"]>>>
vulnerability?: InputMaybe<Array<InputMaybe<Scalars["String"]["input"]>>>
}

Expand All @@ -1332,6 +1334,7 @@ export type RemediationInput = {
service?: InputMaybe<Scalars["String"]["input"]>
severity?: InputMaybe<SeverityValues>
type?: InputMaybe<RemediationTypeValues>
url?: InputMaybe<Scalars["String"]["input"]>
vulnerability?: InputMaybe<Scalars["String"]["input"]>
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -1828,6 +1832,7 @@ export type GetRemediationsQuery = {
expirationDate?: any | null
remediationDate?: any | null
remediatedBy?: string | null
url?: string | null
}
} | null> | null
} | null
Expand Down Expand Up @@ -2189,6 +2194,7 @@ export const CreateRemediationDocument = gql`
serviceId
severity
type
url
vulnerability
vulnerabilityId
}
Expand All @@ -2213,6 +2219,7 @@ export const GetRemediationsDocument = gql`
expirationDate
remediationDate
remediatedBy
url
}
}
totalCount
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mutation CreateRemediation($input: RemediationInput!) {
serviceId
severity
type
url
vulnerability
vulnerabilityId
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ query GetRemediations($filter: RemediationFilter) {
expirationDate
remediationDate
remediatedBy
url
}
}
totalCount
Expand Down
Loading