diff --git a/.changeset/gold-stars-yawn.md b/.changeset/gold-stars-yawn.md new file mode 100644 index 000000000..dd94ceb33 --- /dev/null +++ b/.changeset/gold-stars-yawn.md @@ -0,0 +1,5 @@ +--- +"@cobaltcore-dev/aurora": patch +--- + +added clavis CA create certificates functionality diff --git a/packages/aurora/docs/0011_clavis.md b/packages/aurora/docs/0011_clavis.md index 2c403d821..6048e1fe6 100644 --- a/packages/aurora/docs/0011_clavis.md +++ b/packages/aurora/docs/0011_clavis.md @@ -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 @@ -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: @@ -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: + +- `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` diff --git a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/-modals/IssueEndEntityCertificateModal.test.tsx b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/-modals/IssueEndEntityCertificateModal.test.tsx new file mode 100644 index 000000000..71dab3931 --- /dev/null +++ b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/-modals/IssueEndEntityCertificateModal.test.tsx @@ -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( + + + + + + ) + +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) + }) +}) diff --git a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/-modals/IssueEndEntityCertificateModal.tsx b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/-modals/IssueEndEntityCertificateModal.tsx new file mode 100644 index 000000000..093ec78f6 --- /dev/null +++ b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/-modals/IssueEndEntityCertificateModal.tsx @@ -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), + }) + + 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 ( + 0)} + > + {createCertificateMutation.error && ( + + {createCertificateMutation.error?.message} + + )} + + {isPending && ( +
+ + + Issuing End Entity Certificate... + +
+ )} + + {!isPending && ( +
{ + e.preventDefault() + form.handleSubmit() + }} + > + + ( +