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