Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/gold-stars-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobaltcore-dev/aurora": patch
---

added clavis CA create certificates functionality
10 changes: 9 additions & 1 deletion packages/aurora/docs/0011_clavis.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ Implemented screens and interactions:
- CA details page at `/projects/$projectId/services/pca/$pcaId/` via `PcaDetailsView`
- details page shows CA metadata, certificate validity, CSR content, and delete action
- details-page delete flow reuses the shared delete modal and redirects back to the PCA list after success
- details page supports lifecycle action in `AWAITING_CERTIFICATE` state to issue a self-signed CA certificate from the CA CSR
- certificate list view via `PcaCertificatesListContainer` displays certificates issued by a CA
- certificates list shows CA ID and certificate ID columns with loading, error, and empty states
- disabled "Issue End Entity Certificate" button (placeholder for future issue-certificate task)
- in `READY` state, certificate list provides "Issue End Entity Certificate" action and modal to issue end-entity certificates
- individual certificate rows rendered via `PcaCertificatesTableRow` component, clicking a row navigates to the certificate detail page
- certificate detail page at `/projects/$projectId/services/pca/$pcaId/$certificateId` shows CA ID, certificate ID, duration/validity, and CSR content with loading, error, and not-found states

Expand All @@ -54,6 +55,8 @@ The PCA router is project-scoped and talks to the OpenStack PCA / Clavis service

All endpoints expect `project_id` in the request context or input and use the OpenStack service client exposed by the Aurora BFF.

`createCertificate` issues a new X.509 certificate from the specified Certificate Authority using a provided Certificate Signing Request (CSR).

## Data Model Notes

Relevant PCA states are:
Expand All @@ -66,6 +69,11 @@ Relevant PCA states are:

A newly created CA starts in `CREATING`. Once its CSR is generated, it moves to `AWAITING_CERTIFICATE`. Importing the certificate chain transitions it to `READY`, at which point it can issue end-entity certificates.

Certificate issuing behavior by state:
Comment thread
vlad-schur-external-sap marked this conversation as resolved.

- `AWAITING_CERTIFICATE`: the CA can issue only a self-signed certificate for its own CSR.
- `READY`: terminal operational state in which the CA can issue end-entity certificates.

The CA schema also includes:

- `configuration.subject.common_name`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { act, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { I18nProvider } from "@lingui/react"
import { i18n } from "@lingui/core"
import { PortalProvider } from "@cloudoperators/juno-ui-components"
import { IssueEndEntityCertificateModal } from "./IssueEndEntityCertificateModal"

const mockProjectId = "project-123"
const mockMutateAsync = vi.fn().mockResolvedValue({})
const mockReset = vi.fn()
const mockInvalidate = vi.fn()

vi.mock("@/client/hooks", () => ({
useProjectId: () => mockProjectId,
}))

vi.mock("@/client/trpcClient", () => ({
trpcReact: {
useUtils: () => ({
services: {
pca: {
listCertificates: {
invalidate: mockInvalidate,
},
},
},
}),
services: {
pca: {
createCertificate: {
useMutation: (options?: { onSettled?: () => void }) => ({
isPending: false,
mutateAsync: async (input: unknown) => {
const result = await mockMutateAsync(input)
options?.onSettled?.()
return result
},
reset: mockReset,
error: null,
}),
},
},
},
},
}))

const renderModal = (onClose = vi.fn()) =>
render(
<I18nProvider i18n={i18n}>
<PortalProvider>
<IssueEndEntityCertificateModal open={true} onClose={onClose} pcaId="ca-1" />
</PortalProvider>
</I18nProvider>
)

describe("IssueEndEntityCertificateModal", () => {
beforeEach(async () => {
vi.clearAllMocks()
await act(async () => {
i18n.activate("en")
})
})

afterEach(() => {
vi.restoreAllMocks()
})

it("does not submit when csr is empty", async () => {
const user = userEvent.setup()

renderModal()
await user.click(screen.getByRole("button", { name: "Save" }))

await waitFor(() => {
expect(mockMutateAsync).not.toHaveBeenCalled()
})
})

it("submits normalized csr payload and closes modal", async () => {
const user = userEvent.setup()
const onClose = vi.fn()
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000)

renderModal(onClose)

await user.type(screen.getByPlaceholderText("Paste CSR code"), "line1\\nline2")
await user.click(screen.getByRole("button", { name: "Save" }))

await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
project_id: "project-123",
certificate_authority_id: "ca-1",
csr: "line1\nline2",
configuration: {
validity: {
not_after: 1_700_028_800,
},
},
})
})

expect(mockInvalidate).toHaveBeenCalledTimes(1)
expect(mockReset).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { z } from "zod"
import { useForm, useStore } from "@tanstack/react-form"
import { Trans, useLingui } from "@lingui/react/macro"
import { Modal, Form, FormSection, Spinner, Message, Textarea } from "@cloudoperators/juno-ui-components"
import { trpcReact } from "@/client/trpcClient"
import { useProjectId } from "@/client/hooks"

export interface IssueEndEntityCertificateModalProps {
open: boolean
onClose: () => void
pcaId: string
}

export const IssueEndEntityCertificateModal = ({ open, onClose, pcaId }: IssueEndEntityCertificateModalProps) => {
const { t } = useLingui()
const projectId = useProjectId()
const utils = trpcReact.useUtils()

const { isPending, ...createCertificateMutation } = trpcReact.services.pca.createCertificate.useMutation({
onSettled: () => utils.services.pca.listCertificates.invalidate(),
})

const formSchema = z.object({
csr: z.string().trim().min(1),
})
Comment thread
vlad-schur-external-sap marked this conversation as resolved.
Comment thread
vlad-schur-external-sap marked this conversation as resolved.

const form = useForm({
defaultValues: {
csr: "",
},
validators: {
onSubmit: formSchema,
},
onSubmit: async ({ value }) => {
if (isPending) return

await createCertificateMutation.mutateAsync({
project_id: projectId,
certificate_authority_id: pcaId,
// Normalize to one format so users can paste raw multi-line CSRs with \n along with already formatted ones
csr: value.csr.replace(/\\n/g, "\n"),
configuration: { validity: { not_after: Math.floor(Date.now() / 1000) + 8 * 60 * 60 } },
})
handleClose()
},
})

const handleClose = () => {
if (isPending) return

form.reset()
createCertificateMutation.reset()
onClose()
}

const currentCsr = useStore(form.store, (state) => state.values.csr)

return (
<Modal
open={open}
size="large"
title={t`Issue End Entity Certificate`}
onCancel={handleClose}
cancelButtonLabel={t`Cancel`}
confirmButtonLabel={t`Save`}
onConfirm={form.handleSubmit}
disableConfirmButton={isPending || !(currentCsr.trim().length > 0)}
>
{createCertificateMutation.error && (
<Message dismissible={false} variant="error" className="mb-4">
{createCertificateMutation.error?.message}
</Message>
)}

{isPending && (
<div className="mb-4 flex items-center justify-center gap-2">
<Spinner variant="primary" />
<span className="text-theme-high text-sm">
<Trans>Issuing End Entity Certificate...</Trans>
</span>
</div>
)}

{!isPending && (
<Form
className="mb-0"
id="issue-end-entity-certificate-form"
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<FormSection>
<form.Field
name="csr"
children={(field) => (
<Textarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={t`Paste CSR code`}
errortext={field.state.meta.errors.map((e) => e?.message).join(", ")}
disabled={isPending}
/>
)}
/>
</FormSection>
</Form>
)}
</Modal>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { act, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { I18nProvider } from "@lingui/react"
import { i18n } from "@lingui/core"
import { PortalProvider } from "@cloudoperators/juno-ui-components"
import type { CertificateAuthority } from "@/server/Services/types/pca"
import { IssueSelfSignedCertificateModal } from "./IssueSelfSignedCertificateModal"

const mockProjectId = "project-123"
const mockMutateAsync = vi.fn().mockResolvedValue({})
const mockReset = vi.fn()
const mockInvalidate = vi.fn()

vi.mock("@/client/hooks", () => ({
useProjectId: () => mockProjectId,
}))

vi.mock("@/client/trpcClient", () => ({
trpcReact: {
useUtils: () => ({
services: {
pca: {
listCertificates: {
invalidate: mockInvalidate,
},
},
},
}),
services: {
pca: {
createCertificate: {
useMutation: (options?: { onSettled?: () => void }) => ({
isPending: false,
mutateAsync: async (input: unknown) => {
const result = await mockMutateAsync(input)
options?.onSettled?.()
return result
},
reset: mockReset,
error: null,
}),
},
},
},
},
}))

const createPca = (csr?: string): CertificateAuthority => ({
id: "ca-1",
project_id: "project-123",
state: "AWAITING_CERTIFICATE",
csr,
})

const renderModal = (pca: CertificateAuthority, onClose = vi.fn()) =>
render(
<I18nProvider i18n={i18n}>
<PortalProvider>
<IssueSelfSignedCertificateModal open={true} onClose={onClose} pca={pca} />
</PortalProvider>
</I18nProvider>
)

describe("IssueSelfSignedCertificateModal", () => {
beforeEach(async () => {
vi.clearAllMocks()
await act(async () => {
i18n.activate("en")
})
})

afterEach(() => {
vi.restoreAllMocks()
})

it("does not submit when pca csr is missing", async () => {
renderModal(createPca())
expect(screen.getByRole("button", { name: "Issue Certificate" })).toBeDisabled()

await waitFor(() => {
expect(mockMutateAsync).not.toHaveBeenCalled()
})
})

it("submits self-signed certificate payload and closes modal", async () => {
const user = userEvent.setup()
const onClose = vi.fn()
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000)

renderModal(createPca("-----BEGIN CERTIFICATE REQUEST-----\nabc\n-----END CERTIFICATE REQUEST-----"), onClose)
await user.click(screen.getByRole("button", { name: "Issue Certificate" }))

await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
project_id: "project-123",
certificate_authority_id: "ca-1",
csr: "-----BEGIN CERTIFICATE REQUEST-----\nabc\n-----END CERTIFICATE REQUEST-----",
configuration: {
validity: {
not_after: 1_700_086_400,
},
},
})
})

expect(mockInvalidate).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
Loading
Loading