diff --git a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.test.tsx b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.test.tsx index cb7da5802..9703a2229 100644 --- a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.test.tsx +++ b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.test.tsx @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { I18nProvider } from "@lingui/react" import { i18n } from "@lingui/core" -import type { Certificate } from "@/server/Services/types/pca" +import type { Certificate, CertificatesList } from "@/server/Services/types/pca" import { trpcReact } from "@/client/trpcClient" import { PcaCertificatesListContainer } from "./PcaCertificatesListContainer" @@ -44,6 +44,38 @@ vi.mock("./-modals/IssueEndEntityCertificateModal", () => ({ IssueEndEntityCertificateModal: ({ open }: { open: boolean }) => (open ?
Issue End-Entity Modal
: null), })) +const makeCertificates = (count: number): Certificate[] => + Array.from({ length: count }, (_, index) => ({ + id: `cert-${index + 1}`, + certificate_authority_id: "ca-1", + project_id: "project-1", + })) + +const makeListResponse = ( + certificates: Certificate[], + overrides: Partial = {} +): CertificatesList => ({ + certificates, + links: [], + ...overrides, +}) + +type MockQueryResult = { + data: CertificatesList | undefined + isLoading: boolean + isError: boolean + error: { message?: string } | null +} + +const createMockQueryResult = (overrides: Partial = {}) => + ({ + data: makeListResponse([]), + isLoading: false, + isError: false, + error: null, + ...overrides, + }) as ReturnType + describe("PcaCertificatesListContainer", () => { const validCertificate: Certificate = { id: "cert-1", @@ -69,28 +101,22 @@ describe("PcaCertificatesListContainer", () => { ) it("calls listCertificates query with correct parameters", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue(createMockQueryResult()) renderComponent() expect(vi.mocked(trpcReact.services.pca.listCertificates.useQuery)).toHaveBeenCalledWith({ project_id: "project-1", certificate_authority_id: "ca-1", + limit: 50, + next_page_marker: undefined, }) }) it("renders loading state", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: true, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ isLoading: true }) + ) renderComponent() @@ -99,12 +125,9 @@ describe("PcaCertificatesListContainer", () => { it("renders error state with error message", () => { const errorMessage = "Failed to fetch certificates" - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: false, - isError: true, - error: { message: errorMessage }, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ isError: true, error: { message: errorMessage } }) + ) renderComponent() @@ -112,12 +135,9 @@ describe("PcaCertificatesListContainer", () => { }) it("renders default error message when no error details provided", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: false, - isError: true, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ isError: true, error: null }) + ) renderComponent() @@ -125,12 +145,7 @@ describe("PcaCertificatesListContainer", () => { }) it("renders empty state when no certificates exist", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue(createMockQueryResult()) renderComponent() @@ -140,12 +155,9 @@ describe("PcaCertificatesListContainer", () => { }) it("renders data grid with certificates", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [validCertificate, validCertificate2], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ data: makeListResponse([validCertificate, validCertificate2]) }) + ) renderComponent() @@ -156,12 +168,9 @@ describe("PcaCertificatesListContainer", () => { it("shows issue certificate action for READY state and opens modal", async () => { const user = userEvent.setup() - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [validCertificate], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ data: makeListResponse([validCertificate]) }) + ) renderComponent() @@ -173,12 +182,9 @@ describe("PcaCertificatesListContainer", () => { }) it("does not show issue certificate action when state is not READY", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [validCertificate], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ data: makeListResponse([validCertificate]) }) + ) renderComponent("AWAITING_CERTIFICATE") @@ -188,12 +194,7 @@ describe("PcaCertificatesListContainer", () => { it("shows issue certificate action in empty state when READY and opens modal", async () => { const user = userEvent.setup() - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue(createMockQueryResult()) renderComponent() @@ -205,12 +206,7 @@ describe("PcaCertificatesListContainer", () => { }) it("does not show issue certificate action in empty state when state is not READY", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue(createMockQueryResult()) renderComponent("AWAITING_CERTIFICATE") @@ -218,12 +214,9 @@ describe("PcaCertificatesListContainer", () => { }) it("renders correct column headers", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [validCertificate], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ data: makeListResponse([validCertificate]) }) + ) renderComponent() @@ -232,18 +225,11 @@ describe("PcaCertificatesListContainer", () => { }) it("renders multiple certificates with different IDs", () => { - const cert3: Certificate = { - id: "cert-3", - certificate_authority_id: "ca-1", - project_id: "project-1", - } + const cert3: Certificate = { id: "cert-3", certificate_authority_id: "ca-1", project_id: "project-1" } - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [validCertificate, validCertificate2, cert3], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ data: makeListResponse([validCertificate, validCertificate2, cert3]) }) + ) renderComponent() @@ -252,13 +238,63 @@ describe("PcaCertificatesListContainer", () => { expect(screen.getByTestId("certificate-row-cert-3")).toBeInTheDocument() }) + it("does not render pagination when there is only one page", () => { + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ data: makeListResponse(makeCertificates(10)) }) + ) + + renderComponent() + + expect(screen.queryByRole("button", { name: /previous/i })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /next/i })).not.toBeInTheDocument() + }) + + it("renders pagination when the response includes a next page marker", () => { + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ + data: makeListResponse(makeCertificates(50), { next_page_marker: "next-marker" }), + }) + ) + + renderComponent() + + expect(screen.getByRole("button", { name: /previous/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /next/i })).toBeInTheDocument() + expect(screen.getByTestId("certificate-row-cert-1")).toBeInTheDocument() + expect(screen.getByTestId("certificate-row-cert-50")).toBeInTheDocument() + }) + + it("moves to the next page when next is clicked", () => { + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockImplementation((input) => { + if (typeof input === "symbol") return createMockQueryResult() + if (input.next_page_marker === "marker-page-2") { + return createMockQueryResult({ + data: makeListResponse([{ id: "cert-51", certificate_authority_id: "ca-1", project_id: "project-1" }]), + }) + } + return createMockQueryResult({ + data: makeListResponse(makeCertificates(50), { next_page_marker: "marker-page-2" }), + }) + }) + + renderComponent() + + fireEvent.click(screen.getByRole("button", { name: /next/i })) + + expect(vi.mocked(trpcReact.services.pca.listCertificates.useQuery)).toHaveBeenLastCalledWith({ + project_id: "project-1", + certificate_authority_id: "ca-1", + limit: 50, + next_page_marker: "marker-page-2", + }) + expect(screen.queryByTestId("certificate-row-cert-1")).not.toBeInTheDocument() + expect(screen.getByTestId("certificate-row-cert-51")).toBeInTheDocument() + }) + it("uses default empty array when data is undefined", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue( + createMockQueryResult({ data: undefined }) + ) renderComponent() @@ -266,12 +302,7 @@ describe("PcaCertificatesListContainer", () => { }) it("renders with different pcaId prop", () => { - vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue({ - data: [], - isLoading: false, - isError: false, - error: null, - } as never) + vi.mocked(trpcReact.services.pca.listCertificates.useQuery).mockReturnValue(createMockQueryResult()) const { rerender } = render( @@ -282,6 +313,8 @@ describe("PcaCertificatesListContainer", () => { expect(vi.mocked(trpcReact.services.pca.listCertificates.useQuery)).toHaveBeenCalledWith({ project_id: "project-1", certificate_authority_id: "ca-1", + limit: 50, + next_page_marker: undefined, }) rerender( @@ -293,6 +326,8 @@ describe("PcaCertificatesListContainer", () => { expect(vi.mocked(trpcReact.services.pca.listCertificates.useQuery)).toHaveBeenCalledWith({ project_id: "project-1", certificate_authority_id: "ca-2", + limit: 50, + next_page_marker: undefined, }) }) }) diff --git a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.tsx b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.tsx index 71c49d4c3..ef49754b3 100644 --- a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.tsx +++ b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/$pcaId/-components/PcaCertificatesListContainer.tsx @@ -1,4 +1,5 @@ import { Trans, useLingui } from "@lingui/react/macro" +import { useState } from "react" import { Stack, Spinner, @@ -8,6 +9,7 @@ import { ContentHeading, DataGridHeadCell, Button, + Pagination, } from "@cloudoperators/juno-ui-components" import { CertificateAuthority } from "@/server/Services/types/pca" import { trpcReact } from "@/client/trpcClient" @@ -16,6 +18,8 @@ import { useModal } from "@/client/utils/useModal" import { PcaCertificatesTableRow } from "./-table/PcaCertificatesTableRow" import { IssueEndEntityCertificateModal } from "./-modals/IssueEndEntityCertificateModal" +const ITEMS_PER_PAGE = 50 + interface PcaCertificatesListContainerProps { pcaId: string pcaState: CertificateAuthority["state"] @@ -25,6 +29,8 @@ export const PcaCertificatesListContainer = ({ pcaId, pcaState }: PcaCertificate const { t } = useLingui() const projectId = useProjectId() const [createIssueEndEntityOpen, toggleIssueEndEntity] = useModal(false) + const [pageMarkers, setPageMarkers] = useState<(string | undefined)[]>([undefined]) + const [currentPage, setCurrentPage] = useState(1) const columns = () => [ @@ -34,16 +40,33 @@ export const PcaCertificatesListContainer = ({ pcaId, pcaState }: PcaCertificate ] as const const columnsLength = columns().length - const { - data: pcaCertificates = [], - isLoading, - isError, - error, - } = trpcReact.services.pca.listCertificates.useQuery({ + const currentMarker = pageMarkers[currentPage - 1] + + const { data, isLoading, isError, error } = trpcReact.services.pca.listCertificates.useQuery({ project_id: projectId, certificate_authority_id: pcaId, + limit: ITEMS_PER_PAGE, + next_page_marker: currentMarker, }) + const certificates = data?.certificates ?? [] + const nextMarker = data?.next_page_marker + const hasNextPage = !!nextMarker + const computedTotal = hasNextPage ? currentPage + 1 : currentPage + const totalPages = Math.max(computedTotal, pageMarkers.length) + + const goToPage = (page: number) => { + if (page < 1 || page > totalPages) return + if (page > currentPage && nextMarker) { + setPageMarkers((prev) => { + const updated = [...prev] + updated[page - 1] = nextMarker + return updated + }) + } + setCurrentPage(page) + } + if (isLoading) { return ( @@ -78,7 +101,7 @@ export const PcaCertificatesListContainer = ({ pcaId, pcaState }: PcaCertificate )} - {pcaCertificates.length === 0 ? ( + {certificates.length === 0 && currentPage === 1 ? ( @@ -98,11 +121,23 @@ export const PcaCertificatesListContainer = ({ pcaId, pcaState }: PcaCertificate {label} ))} - {pcaCertificates.map((certificate) => ( + {certificates.map((certificate) => ( ))} )} + + {totalPages > 1 && ( +
+ goToPage(currentPage - 1)} + onPressNext={() => goToPage(currentPage + 1)} + /> +
+ )} ) } diff --git a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.test.tsx b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.test.tsx index 3a6d16d9d..8c6be1748 100644 --- a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.test.tsx +++ b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" import { I18nProvider } from "@lingui/react" import { i18n } from "@lingui/core" -import type { CertificateAuthority } from "@/server/Services/types/pca" +import type { CertificateAuthority, CertificateAuthoritiesList } from "@/server/Services/types/pca" import { trpcReact } from "@/client/trpcClient" import { PcaListContainer } from "./PcaListContainer" @@ -37,7 +37,7 @@ vi.mock("@/client/trpcClient", async (importOriginal) => { }) type MockListQueryResult = { - data: CertificateAuthority[] | undefined + data: CertificateAuthoritiesList | undefined isLoading: boolean isError: boolean error: { message?: string } | null @@ -54,6 +54,22 @@ const createMockListQueryResult = (overrides: Partial = {}) ...overrides, }) as ReturnType +const makePcas = (count: number): CertificateAuthority[] => + Array.from({ length: count }, (_, index) => ({ + id: `pca-${index + 1}`, + project_id: mockProjectId, + state: "READY", + })) + +const makeListResponse = ( + certificateAuthorities: CertificateAuthority[], + overrides: Partial = {} +): CertificateAuthoritiesList => ({ + certificate_authorities: certificateAuthorities, + links: [], + ...overrides, +}) + const renderComponent = () => render( @@ -88,7 +104,9 @@ describe("PcaListContainer", () => { }) it("renders empty state", () => { - vi.mocked(trpcReact.services.pca.list.useQuery).mockReturnValue(createMockListQueryResult({ data: [] })) + vi.mocked(trpcReact.services.pca.list.useQuery).mockReturnValue( + createMockListQueryResult({ data: makeListResponse([]) }) + ) renderComponent() @@ -99,10 +117,10 @@ describe("PcaListContainer", () => { it("renders rows when data exists and queries with project id", () => { vi.mocked(trpcReact.services.pca.list.useQuery).mockReturnValue( createMockListQueryResult({ - data: [ + data: makeListResponse([ { id: "pca-1", project_id: mockProjectId, state: "READY" }, { id: "pca-2", project_id: mockProjectId, state: "FAILED" }, - ], + ]), }) ) @@ -110,6 +128,67 @@ describe("PcaListContainer", () => { expect(screen.getByTestId("pca-row-pca-1")).toBeInTheDocument() expect(screen.getByTestId("pca-row-pca-2")).toBeInTheDocument() - expect(vi.mocked(trpcReact.services.pca.list.useQuery)).toHaveBeenCalledWith({ project_id: mockProjectId }) + expect(vi.mocked(trpcReact.services.pca.list.useQuery)).toHaveBeenCalledWith({ + project_id: mockProjectId, + limit: 50, + next_page_marker: undefined, + }) + }) + + it("does not render pagination when there is only one page", () => { + vi.mocked(trpcReact.services.pca.list.useQuery).mockReturnValue( + createMockListQueryResult({ + data: makeListResponse(makePcas(10)), + }) + ) + + renderComponent() + + expect(screen.queryByRole("button", { name: /previous/i })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /next/i })).not.toBeInTheDocument() + }) + + it("renders pagination when the response includes a next page marker", () => { + vi.mocked(trpcReact.services.pca.list.useQuery).mockReturnValue( + createMockListQueryResult({ + data: makeListResponse(makePcas(10), { next_page_marker: "next-marker" }), + }) + ) + + renderComponent() + + expect(screen.getByRole("button", { name: /previous/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /next/i })).toBeInTheDocument() + expect(screen.getByTestId("pca-row-pca-1")).toBeInTheDocument() + expect(screen.getByTestId("pca-row-pca-10")).toBeInTheDocument() + }) + + it("moves to the next page when next is clicked", () => { + vi.mocked(trpcReact.services.pca.list.useQuery).mockImplementation((input) => { + if (typeof input === "symbol") return createMockListQueryResult() + if (input.next_page_marker === "marker-page-2") { + return createMockListQueryResult({ + data: makeListResponse([{ id: "pca-11", project_id: mockProjectId, state: "READY" }]), + }) + } + + return createMockListQueryResult({ + data: makeListResponse(makePcas(10), { + next_page_marker: "marker-page-2", + }), + }) + }) + + renderComponent() + + fireEvent.click(screen.getByRole("button", { name: /next/i })) + + expect(vi.mocked(trpcReact.services.pca.list.useQuery)).toHaveBeenLastCalledWith({ + project_id: mockProjectId, + limit: 50, + next_page_marker: "marker-page-2", + }) + expect(screen.queryByTestId("pca-row-pca-1")).not.toBeInTheDocument() + expect(screen.getByTestId("pca-row-pca-11")).toBeInTheDocument() }) }) diff --git a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.tsx b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.tsx index cea6cc37d..558962568 100644 --- a/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.tsx +++ b/packages/aurora/src/client/routes/_auth/projects/$projectId/services/pca/-components/PcaListContainer.tsx @@ -1,4 +1,5 @@ import { Trans, useLingui } from "@lingui/react/macro" +import { useState } from "react" import { Stack, Spinner, @@ -8,6 +9,7 @@ import { ContentHeading, DataGridHeadCell, Button, + Pagination, } from "@cloudoperators/juno-ui-components" import { trpcReact } from "@/client/trpcClient" import { useProjectId } from "@/client/hooks" @@ -16,14 +18,41 @@ import { TABLE_COLUMNS } from "./-table/constants" import { PcaTableRow } from "./-table/PcaTableRow" import { CreatePcaModal } from "./-modals/CreatePcaModal" +const ITEMS_PER_PAGE = 50 + export const PcaListContainer = () => { const { t } = useLingui() const projectId = useProjectId() const columns = TABLE_COLUMNS() const columnsLength = columns.length const [createCaOpen, toggleCreateCa] = useModal(false) + const [pageMarkers, setPageMarkers] = useState<(string | undefined)[]>([undefined]) + const [currentPage, setCurrentPage] = useState(1) + + const currentMarker = pageMarkers[currentPage - 1] + + const { data, isLoading, isError, error } = trpcReact.services.pca.list.useQuery({ + project_id: projectId, + limit: ITEMS_PER_PAGE, + next_page_marker: currentMarker, + }) - const { data: pcas = [], isLoading, isError, error } = trpcReact.services.pca.list.useQuery({ project_id: projectId }) + const pcas = data?.certificate_authorities ?? [] + const nextMarker = data?.next_page_marker + const computedTotal = nextMarker ? currentPage + 1 : currentPage + const totalPages = Math.max(computedTotal, pageMarkers.length) + + const goToPage = (page: number) => { + if (page < 1 || page > totalPages) return + if (page > currentPage && nextMarker) { + setPageMarkers((prev) => { + const updated = [...prev] + updated[page - 1] = nextMarker + return updated + }) + } + setCurrentPage(page) + } if (isLoading) { return ( @@ -42,7 +71,7 @@ export const PcaListContainer = () => { ) } - if (pcas.length === 0) { + if (pcas.length === 0 && currentPage === 1) { return ( @@ -75,6 +104,18 @@ export const PcaListContainer = () => { ))} + {totalPages > 1 && ( +
+ goToPage(currentPage - 1)} + onPressNext={() => goToPage(currentPage + 1)} + /> +
+ )} + {createCaOpen && } ) diff --git a/packages/aurora/src/server/Services/routers/pcaRouter.test.ts b/packages/aurora/src/server/Services/routers/pcaRouter.test.ts index 747c7ab3d..ee43b4f15 100644 --- a/packages/aurora/src/server/Services/routers/pcaRouter.test.ts +++ b/packages/aurora/src/server/Services/routers/pcaRouter.test.ts @@ -18,6 +18,8 @@ const validListResponse = { }, }, ], + links: [{ href: "/v1/certificate-authorities?limit=10&next_page_marker=next-marker", rel: "next" }], + next_page_marker: "next-marker", } const validGetByIdResponse = { @@ -57,6 +59,10 @@ const validCertificatesResponse = { csr: "-----BEGIN CERTIFICATE REQUEST-----\nMIIBkTCB+wIJAKHHC...ABC123==\n-----END CERTIFICATE REQUEST-----", }, ], + links: [ + { href: "/v1/certificate-authorities/ca-1/certificates?limit=50&next_page_marker=next-cert-marker", rel: "next" }, + ], + next_page_marker: "next-cert-marker", } const validGetByIdCertificateResponse = validCertificatesResponse.certificates[0] @@ -121,7 +127,7 @@ const createMockContext = (opts?: { responseBody = getByIdCertificateParseError ? { invalid: true } : validGetByIdCertificateResponse } else if (url.includes("/certificates")) { responseBody = certificateParseError ? { invalid: true } : validCertificatesResponse - } else if (url === "certificate-authorities") { + } else if (url === "certificate-authorities" || url.startsWith("certificate-authorities?")) { responseBody = parseError ? { invalid: true } : validListResponse } else { responseBody = getByIdParseError ? { invalid: true } : validGetByIdResponse @@ -201,11 +207,25 @@ describe("pcaRouter", () => { const result = await caller.services.pca.list({ project_id: TEST_PROJECT_ID }) - expect(result).toEqual(validListResponse.certificate_authorities) + expect(result).toEqual(validListResponse) expect(ctx.__serviceMock).toHaveBeenCalledWith("pca") expect(ctx.__getMock).toHaveBeenCalledWith("certificate-authorities") }) + it("forwards pagination query params for list requests", async () => { + const ctx = createMockContext() + const caller = createCaller(ctx as never) + + const result = await caller.services.pca.list({ + project_id: TEST_PROJECT_ID, + limit: 10, + next_page_marker: "next-marker", + }) + + expect(result).toEqual(validListResponse) + expect(ctx.__getMock).toHaveBeenCalledWith("certificate-authorities?limit=10&next_page_marker=next-marker") + }) + it("throws INTERNAL_SERVER_ERROR when pca service is unavailable", async () => { const ctx = createMockContext({ noClavis: true }) const caller = createCaller(ctx as never) @@ -322,11 +342,28 @@ describe("pcaRouter", () => { certificate_authority_id: "ca-1", }) - expect(result).toEqual(validCertificatesResponse.certificates) + expect(result).toEqual(validCertificatesResponse) expect(ctx.__serviceMock).toHaveBeenCalledWith("pca") expect(ctx.__getMock).toHaveBeenCalledWith("certificate-authorities/ca-1/certificates") }) + it("forwards pagination query params for listCertificates requests", async () => { + const ctx = createMockContext() + const caller = createCaller(ctx as never) + + const result = await caller.services.pca.listCertificates({ + project_id: TEST_PROJECT_ID, + certificate_authority_id: "ca-1", + limit: 50, + next_page_marker: "next-marker", + }) + + expect(result).toEqual(validCertificatesResponse) + expect(ctx.__getMock).toHaveBeenCalledWith( + "certificate-authorities/ca-1/certificates?limit=50&next_page_marker=next-marker" + ) + }) + it("throws INTERNAL_SERVER_ERROR when pca service is unavailable", async () => { const ctx = createMockContext({ noClavis: true }) const caller = createCaller(ctx as never) diff --git a/packages/aurora/src/server/Services/routers/pcaRouter.ts b/packages/aurora/src/server/Services/routers/pcaRouter.ts index 4213b8d04..c483d6ae1 100644 --- a/packages/aurora/src/server/Services/routers/pcaRouter.ts +++ b/packages/aurora/src/server/Services/routers/pcaRouter.ts @@ -5,12 +5,16 @@ import { omit } from "@/server/helpers/object" import { parseOrThrow } from "@/server/Network/helpers" import { CertificateAuthoritiesListSchema, + CertificateAuthoritiesListInputSchema, CertificateAuthoritySchema, CertificateAuthorityIdInputSchema, + CertificateAuthorityCertificatesListInputSchema, CertificatesListSchema, CertificateSchema, Certificate, CertificateAuthority, + CertificateAuthoritiesList, + CertificatesList, CertificateIdInputSchema, CertificateAuthorityCreateSchema, CreateCertificateInputSchema, @@ -21,18 +25,26 @@ import { const PCA_BASE_URL = "certificate-authorities" export const pcaRouter = { - list: projectScopedProcedure.query(async ({ ctx }): Promise => { - return withErrorHandling(async () => { - // Use "pca" or "clavis" when the service will be GA as "clavis-beta" and "clavis-dev" are dev keys. - const pca = ctx.openstack?.service("pca") - validateOpenstackService(pca, "pca") - - const response = await pca.get(PCA_BASE_URL) - const data = await response.json() - - return parseOrThrow(CertificateAuthoritiesListSchema, data, "pcaRouter.list").certificate_authorities - }, "list certificate authorities") - }), + list: projectScopedProcedure + .input(CertificateAuthoritiesListInputSchema) + .query(async ({ input, ctx }): Promise => { + return withErrorHandling(async () => { + // Use "pca" or "clavis" when the service will be GA as "clavis-beta" and "clavis-dev" are dev keys. + const pca = ctx.openstack?.service("pca") + validateOpenstackService(pca, "pca") + + const queryParams = new URLSearchParams() + if (input.limit !== undefined) queryParams.set("limit", String(input.limit)) + if (input.next_page_marker !== undefined) queryParams.set("next_page_marker", input.next_page_marker) + const queryString = queryParams.toString() + const url = queryString ? `${PCA_BASE_URL}?${queryString}` : PCA_BASE_URL + + const response = await pca.get(url) + const data = await response.json() + + return parseOrThrow(CertificateAuthoritiesListSchema, data, "pcaRouter.list") + }, "list certificate authorities") + }), /** * Creates a new Certificate Authority (CA). * The CA is created in CREATING state and transitions to AWAITING_CERTIFICATE state once its CSR has been generated. @@ -101,17 +113,22 @@ export const pcaRouter = { }, "import certificate of certificate authority") }), listCertificates: projectScopedProcedure - .input(CertificateAuthorityIdInputSchema) - .query(async ({ input, ctx }): Promise => { + .input(CertificateAuthorityCertificatesListInputSchema) + .query(async ({ input, ctx }): Promise => { return withErrorHandling(async () => { const pca = ctx.openstack?.service("pca") validateOpenstackService(pca, "pca") - const url = `${PCA_BASE_URL}/${input.certificate_authority_id}/certificates` + const queryParams = new URLSearchParams() + if (input.limit !== undefined) queryParams.set("limit", String(input.limit)) + if (input.next_page_marker !== undefined) queryParams.set("next_page_marker", input.next_page_marker) + const queryString = queryParams.toString() + const baseUrl = `${PCA_BASE_URL}/${input.certificate_authority_id}/certificates` + const url = queryString ? `${baseUrl}?${queryString}` : baseUrl const response = await pca.get(url) const data = await response.json() - return parseOrThrow(CertificatesListSchema, data, "pcaRouter.listCertificates").certificates + return parseOrThrow(CertificatesListSchema, data, "pcaRouter.listCertificates") }, "list certificates for certificate authority") }), createCertificate: projectScopedProcedure diff --git a/packages/aurora/src/server/Services/types/pca.test.ts b/packages/aurora/src/server/Services/types/pca.test.ts index a9c640edd..16365ad4f 100644 --- a/packages/aurora/src/server/Services/types/pca.test.ts +++ b/packages/aurora/src/server/Services/types/pca.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from "vitest" import { CertificateAuthoritiesListSchema, + CertificateAuthoritiesListInputSchema, CertificateAuthorityCreateSchema, + CertificateAuthorityCertificatesListInputSchema, CertificateAuthorityImportInputSchema, CertificateAuthoritySchema, CertificateConfigurationSchema, @@ -99,6 +101,39 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { imported_certificate_chain: "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHC...ABC123==\n-----END CERTIFICATE-----", } + const projectScopedSchemaCases = [ + { + name: "CertificateAuthoritiesListInputSchema", + schema: CertificateAuthoritiesListInputSchema, + validInput: { + project_id: " project-1 ", + }, + }, + { + name: "CertificateAuthorityIdInputSchema", + schema: CertificateAuthorityIdInputSchema, + validInput: { + project_id: " project-1 ", + certificate_authority_id: "ca-123", + }, + }, + { + name: "CreateCertificateInputSchema", + schema: CreateCertificateInputSchema, + validInput: { + project_id: " project-1 ", + certificate_authority_id: "ca-123", + csr: "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----", + configuration: { + validity: { + not_after: 1736851200, + not_before: 1705315200, + }, + }, + }, + }, + ] as const + describe("CAStateSchema", () => { it("should validate CREATING state", () => { expect(CertificateAuthoritySchema.safeParse({ ...minimalValidCA, state: "CREATING" }).success).toBe(true) @@ -590,6 +625,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { expect( CertificateAuthoritiesListSchema.safeParse({ certificate_authorities: [minimalValidCA], + links: [], }).success ).toBe(true) }) @@ -598,6 +634,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { expect( CertificateAuthoritiesListSchema.safeParse({ certificate_authorities: [minimalValidCA, minimalValidReadyCA, completeValidCA], + links: [], }).success ).toBe(true) }) @@ -606,6 +643,24 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { expect( CertificateAuthoritiesListSchema.safeParse({ certificate_authorities: [], + links: [], + }).success + ).toBe(true) + }) + + it("should require links field", () => { + expect( + CertificateAuthoritiesListSchema.safeParse({ + certificate_authorities: [minimalValidCA], + }).success + ).toBe(false) + }) + + it("should validate links with items", () => { + expect( + CertificateAuthoritiesListSchema.safeParse({ + certificate_authorities: [minimalValidCA], + links: [{ href: "https://example.com/next", rel: "next" }], }).success ).toBe(true) }) @@ -638,6 +693,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { expect( CertificateAuthoritiesListSchema.safeParse({ certificate_authorities: [creatingCA, awaitingCA, readyCA, failedCA], + links: [], }).success ).toBe(true) }) @@ -657,6 +713,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { expect( CertificateAuthoritiesListSchema.safeParse({ certificate_authorities: [...incompleteCAs, ...completeCAs], + links: [], }).success ).toBe(true) }) @@ -699,12 +756,84 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { }, }, ], + links: [{ href: "https://clavis.example.com/v1/certificate-authorities?next_page_marker=abc", rel: "next" }], } expect(CertificateAuthoritiesListSchema.safeParse(realWorldResponse).success).toBe(true) }) }) + describe("CertificateAuthoritiesListInputSchema", () => { + it("should validate with only required project_id", () => { + expect( + CertificateAuthoritiesListInputSchema.safeParse({ + project_id: "project-1", + }).success + ).toBe(true) + }) + + it("should validate with optional pagination fields", () => { + expect( + CertificateAuthoritiesListInputSchema.safeParse({ + project_id: "project-1", + limit: 100, + next_page_marker: "opaque-marker", + }).success + ).toBe(true) + }) + + it("should reject limit lower than minimum", () => { + expect( + CertificateAuthoritiesListInputSchema.safeParse({ + project_id: "project-1", + limit: 0, + }).success + ).toBe(false) + }) + + it("should reject limit greater than maximum", () => { + expect( + CertificateAuthoritiesListInputSchema.safeParse({ + project_id: "project-1", + limit: 1001, + }).success + ).toBe(false) + }) + + it("should reject empty next_page_marker", () => { + expect( + CertificateAuthoritiesListInputSchema.safeParse({ + project_id: "project-1", + next_page_marker: "", + }).success + ).toBe(false) + }) + + it("should reject input without project_id", () => { + expect(CertificateAuthoritiesListInputSchema.safeParse({ limit: 10 }).success).toBe(false) + }) + }) + + describe("project_id validation consistency", () => { + it.each(projectScopedSchemaCases)("should trim project_id for %s", ({ schema, validInput }) => { + const result = schema.safeParse(validInput) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.project_id).toBe("project-1") + } + }) + + it.each(projectScopedSchemaCases)("should reject whitespace-only project_id for %s", ({ schema, validInput }) => { + expect( + schema.safeParse({ + ...validInput, + project_id: " ", + }).success + ).toBe(false) + }) + }) + describe("CertificateAuthorityIdInputSchema", () => { it("should validate with required project_id and certificate_authority_id", () => { expect( @@ -750,6 +879,58 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { }) }) + describe("CertificateAuthorityCertificatesListInputSchema", () => { + it("should validate with required CA identifiers only", () => { + expect( + CertificateAuthorityCertificatesListInputSchema.safeParse({ + project_id: "project-1", + certificate_authority_id: "ca-123", + }).success + ).toBe(true) + }) + + it("should validate with optional pagination fields", () => { + expect( + CertificateAuthorityCertificatesListInputSchema.safeParse({ + project_id: "project-1", + certificate_authority_id: "ca-123", + limit: 100, + next_page_marker: "opaque-marker", + }).success + ).toBe(true) + }) + + it("should reject limit below minimum", () => { + expect( + CertificateAuthorityCertificatesListInputSchema.safeParse({ + project_id: "project-1", + certificate_authority_id: "ca-123", + limit: 0, + }).success + ).toBe(false) + }) + + it("should reject limit above maximum", () => { + expect( + CertificateAuthorityCertificatesListInputSchema.safeParse({ + project_id: "project-1", + certificate_authority_id: "ca-123", + limit: 1001, + }).success + ).toBe(false) + }) + + it("should reject empty next_page_marker", () => { + expect( + CertificateAuthorityCertificatesListInputSchema.safeParse({ + project_id: "project-1", + certificate_authority_id: "ca-123", + next_page_marker: "", + }).success + ).toBe(false) + }) + }) + describe("CertificateAuthorityImportInputSchema", () => { it("should validate import input with all required fields", () => { expect( @@ -1093,6 +1274,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { expect( CertificatesListSchema.safeParse({ certificates: [validCertificate], + links: [], }).success ).toBe(true) }) @@ -1105,6 +1287,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { { ...validCertificate, id: "cert-2" }, { ...validCertificate, id: "cert-3" }, ], + links: [], }).success ).toBe(true) }) @@ -1113,6 +1296,24 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { expect( CertificatesListSchema.safeParse({ certificates: [], + links: [], + }).success + ).toBe(true) + }) + + it("should require links field", () => { + expect( + CertificatesListSchema.safeParse({ + certificates: [validCertificate], + }).success + ).toBe(false) + }) + + it("should validate links with items", () => { + expect( + CertificatesListSchema.safeParse({ + certificates: [validCertificate], + links: [{ href: "https://example.com/next", rel: "next" }], }).success ).toBe(true) }) @@ -1157,6 +1358,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { }, }, ], + links: [], }).success ).toBe(true) }) @@ -1172,6 +1374,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { csr: "-----BEGIN CERTIFICATE REQUEST-----\n...", }, ], + links: [], }).success ).toBe(true) }) @@ -1190,6 +1393,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { }, }, ], + links: [], }).success ).toBe(true) }) @@ -1239,6 +1443,7 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => { }, }, ], + links: [{ href: "https://clavis.example.com/v1/certificates?next_page_marker=abc", rel: "next" }], } expect(CertificatesListSchema.safeParse(realWorldResponse).success).toBe(true) diff --git a/packages/aurora/src/server/Services/types/pca.ts b/packages/aurora/src/server/Services/types/pca.ts index 8fa094513..7741231b4 100644 --- a/packages/aurora/src/server/Services/types/pca.ts +++ b/packages/aurora/src/server/Services/types/pca.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { projectScopedInputSchema } from "../../trpc" /** PCA (Private Certificate Authority) - Clavis service schemas for certificate authority management */ @@ -73,8 +74,21 @@ export const CertificateAuthoritySchema = z.object({ state: CertificateAuthorityStateSchema, }) +const LinkSchema = z.object({ + href: z.string(), + rel: z.string(), +}) + export const CertificateAuthoritiesListSchema = z.object({ certificate_authorities: z.array(CertificateAuthoritySchema), + links: z.array(LinkSchema), + next_page_marker: z.string().optional(), +}) + +// Used by: /v1/certificate-authorities - List Certificate Authorities +export const CertificateAuthoritiesListInputSchema = projectScopedInputSchema.extend({ + limit: z.number().int().min(1).max(1000).optional(), + next_page_marker: z.string().trim().min(1).optional(), }) // Used by: /v1/certificate-authorities - Create new Certificate Authority @@ -93,11 +107,16 @@ export const CertificateAuthorityCreateSchema = z.object({ * - GET /v1/certificate-authorities/{certificate_authority_id}/certificates - List Certificates * */ -export const CertificateAuthorityIdInputSchema = z.object({ - project_id: z.string(), +export const CertificateAuthorityIdInputSchema = projectScopedInputSchema.extend({ certificate_authority_id: z.string().min(1), }) +// Used by: /v1/certificate-authorities/{certificate_authority_id}/certificates - List Certificates +export const CertificateAuthorityCertificatesListInputSchema = CertificateAuthorityIdInputSchema.extend({ + limit: z.number().int().min(1).max(1000).optional(), + next_page_marker: z.string().trim().min(1).optional(), +}) + // Used by: /v1/certificate-authorities/{certificate_authority_id}:importCertificate - Import certificate of Certificate Authority export const CertificateAuthorityImportInputSchema = CertificateAuthorityIdInputSchema.extend({ imported_certificate_chain: z.string().min(1), @@ -113,8 +132,7 @@ export const CertificateConfigurationSchema = z.object({ }) // Used by: /v1/certificate-authorities/{certificate_authority_id}/certificates - Create new Certificate -export const CreateCertificateInputSchema = z.object({ - project_id: z.string(), +export const CreateCertificateInputSchema = projectScopedInputSchema.extend({ certificate_authority_id: z.string().min(1), csr: z.string().min(1), configuration: CertificateConfigurationSchema, @@ -132,8 +150,12 @@ export const CertificateSchema = z.object({ export const CertificatesListSchema = z.object({ certificates: z.array(CertificateSchema), + links: z.array(LinkSchema), + next_page_marker: z.string().optional(), }) export type Certificate = z.infer export type CertificateAuthority = z.infer export type CertificateAuthorityState = z.infer +export type CertificateAuthoritiesList = z.infer +export type CertificatesList = z.infer