Skip to content

Commit 5612a14

Browse files
feat(bff): clavis create certificate-authority and certificate issued by authority (#774)
* feat(bff): create certificate-authority operation * feat(bff): create certificate by certificate-authority * feat(bff): add missing tests for create operations router and schema * fix(bff): prettier formatting in pca-clavis files * fix(bff): replace query with mutation * fix(bff): tests for pca-router
1 parent 4197817 commit 5612a14

4 files changed

Lines changed: 300 additions & 3 deletions

File tree

apps/aurora-portal/src/server/Services/routers/pcaRouter.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,58 @@ const validGetByIdCertificateResponse = {
6565
certificate: validCertificatesResponse.certificates[0],
6666
}
6767

68+
const validCreateCAResponse = {
69+
certificate_authority: {
70+
id: "ca-new",
71+
project_id: TEST_PROJECT_ID,
72+
state: "CREATING" as const,
73+
configuration: {
74+
subject: {
75+
common_name: "new-ca.example.com",
76+
},
77+
},
78+
},
79+
}
80+
81+
const validCreateCertificateResponse = {
82+
certificate: {
83+
id: "cert-new",
84+
certificate_authority_id: "ca-1",
85+
project_id: TEST_PROJECT_ID,
86+
certificate: {
87+
pem: "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHC...ABC123==\n-----END CERTIFICATE-----",
88+
validity: {
89+
not_before: 1705315200,
90+
not_after: 1736851200,
91+
},
92+
},
93+
configuration: {
94+
validity: {
95+
not_before: 1705315200,
96+
not_after: 1736851200,
97+
},
98+
},
99+
csr: "-----BEGIN CERTIFICATE REQUEST-----\nMIIBkTCB+wIJAKHHC...ABC123==\n-----END CERTIFICATE REQUEST-----",
100+
},
101+
}
102+
68103
const createMockContext = (opts?: {
69104
noClavis?: boolean
70105
parseError?: boolean
71106
certificateParseError?: boolean
72107
getByIdParseError?: boolean
73108
getByIdCertificateParseError?: boolean
109+
createCAParseError?: boolean
110+
createCertificateParseError?: boolean
74111
}) => {
75112
const {
76113
noClavis = false,
77114
parseError = false,
78115
certificateParseError = false,
79116
getByIdParseError = false,
80117
getByIdCertificateParseError = false,
118+
createCAParseError = false,
119+
createCertificateParseError = false,
81120
} = opts || {}
82121

83122
const getMock = vi.fn().mockImplementation((url: string) => {
@@ -97,8 +136,22 @@ const createMockContext = (opts?: {
97136
})
98137
})
99138

139+
const postMock = vi.fn().mockImplementation((url: string) => {
140+
let responseBody: unknown
141+
if (url.includes("/certificates")) {
142+
responseBody = createCertificateParseError ? { invalid: true } : validCreateCertificateResponse
143+
} else {
144+
responseBody = createCAParseError ? { invalid: true } : validCreateCAResponse
145+
}
146+
147+
return Promise.resolve({
148+
json: vi.fn().mockResolvedValue(responseBody),
149+
})
150+
})
151+
100152
const clavisService = {
101153
get: getMock,
154+
post: postMock,
102155
}
103156

104157
const serviceMock = vi.fn().mockImplementation((serviceName: string) => {
@@ -120,6 +173,7 @@ const createMockContext = (opts?: {
120173
getMultipartData: vi.fn(),
121174
__serviceMock: serviceMock,
122175
__getMock: getMock,
176+
__postMock: postMock,
123177
}
124178
}
125179

@@ -322,4 +376,113 @@ describe("pcaRouter", () => {
322376
)
323377
})
324378
})
379+
380+
describe("create", () => {
381+
it("creates certificate authority for valid input", async () => {
382+
const ctx = createMockContext()
383+
const caller = createCaller(ctx as never)
384+
385+
const result = await caller.services.pca.create({
386+
project_id: TEST_PROJECT_ID,
387+
configuration: {
388+
subject: {
389+
common_name: "new-ca.example.com",
390+
},
391+
},
392+
})
393+
394+
expect(result).toEqual(validCreateCAResponse.certificate_authority)
395+
expect(ctx.__postMock).toHaveBeenCalledWith("v1/certificate-authorities", expect.any(Object))
396+
const [, request] = ctx.__postMock.mock.calls[0]
397+
expect(JSON.parse(request.body)).toEqual({
398+
project_id: TEST_PROJECT_ID,
399+
configuration: {
400+
subject: {
401+
common_name: "new-ca.example.com",
402+
},
403+
},
404+
})
405+
})
406+
407+
it("throws PARSE_ERROR on invalid create response payload", async () => {
408+
const ctx = createMockContext({ createCAParseError: true })
409+
const caller = createCaller(ctx as never)
410+
411+
await expect(
412+
caller.services.pca.create({
413+
project_id: TEST_PROJECT_ID,
414+
configuration: {
415+
subject: {
416+
common_name: "new-ca.example.com",
417+
},
418+
},
419+
})
420+
).rejects.toThrow(
421+
new TRPCError({
422+
code: "PARSE_ERROR",
423+
message: "Failed to parse response in pcaRouter.create",
424+
})
425+
)
426+
})
427+
})
428+
429+
describe("createCertificate", () => {
430+
it("creates certificate for valid input", async () => {
431+
const ctx = createMockContext()
432+
const caller = createCaller(ctx as never)
433+
434+
const result = await caller.services.pca.createCertificate({
435+
project_id: TEST_PROJECT_ID,
436+
certificate_authority_id: "ca-1",
437+
certificate: {
438+
configuration: {
439+
validity: {
440+
not_before: 1705315200,
441+
not_after: 1736851200,
442+
},
443+
},
444+
csr: "-----BEGIN CERTIFICATE REQUEST-----\nMIIBkTCB+wIJAKHHC...ABC123==\n-----END CERTIFICATE REQUEST-----",
445+
},
446+
})
447+
448+
expect(result).toEqual(validCreateCertificateResponse.certificate)
449+
expect(ctx.__postMock).toHaveBeenCalledWith("v1/certificate-authorities/ca-1/certificates", expect.any(Object))
450+
const [, request] = ctx.__postMock.mock.calls[0]
451+
expect(JSON.parse(request.body)).toEqual({
452+
configuration: {
453+
validity: {
454+
not_before: 1705315200,
455+
not_after: 1736851200,
456+
},
457+
},
458+
csr: "-----BEGIN CERTIFICATE REQUEST-----\nMIIBkTCB+wIJAKHHC...ABC123==\n-----END CERTIFICATE REQUEST-----",
459+
})
460+
})
461+
462+
it("throws PARSE_ERROR on invalid create certificate response payload", async () => {
463+
const ctx = createMockContext({ createCertificateParseError: true })
464+
const caller = createCaller(ctx as never)
465+
466+
await expect(
467+
caller.services.pca.createCertificate({
468+
project_id: TEST_PROJECT_ID,
469+
certificate_authority_id: "ca-1",
470+
certificate: {
471+
configuration: {
472+
validity: {
473+
not_before: 1705315200,
474+
not_after: 1736851200,
475+
},
476+
},
477+
csr: "-----BEGIN CERTIFICATE REQUEST-----\nMIIBkTCB+wIJAKHHC...ABC123==\n-----END CERTIFICATE REQUEST-----",
478+
},
479+
})
480+
).rejects.toThrow(
481+
new TRPCError({
482+
code: "PARSE_ERROR",
483+
message: "Failed to parse response in pcaRouter.createCertificate",
484+
})
485+
)
486+
})
487+
})
325488
})

apps/aurora-portal/src/server/Services/routers/pcaRouter.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
CertificateAuthorityResponseSchema,
1212
CertificateIdInputSchema,
1313
CertificateResponseSchema,
14+
CertificateAuthorityCreateSchema,
15+
CreateCertificateInputSchema,
1416
} from "../types/pca"
1517

1618
/** PCA (Private Certificate Authority) - Clavis service for certificate authority management */
@@ -28,6 +30,25 @@ export const pcaRouter = {
2830
return parseOrThrow(CertificateAuthoritiesListSchema, data, "pcaRouter.list").certificate_authorities
2931
}, "list certificate authorities")
3032
}),
33+
/**
34+
* Creates a new Certificate Authority (CA).
35+
* The CA is created in CREATING state and transitions to AWAITING_CERTIFICATE state once its CSR has been generated.
36+
* CA in AWAITING_CERTIFICATE can't issue end-entity certificates, and needs to have its certificate imported - see the respective importCertificate endpoint.
37+
*/
38+
create: projectScopedProcedure
39+
.input(CertificateAuthorityCreateSchema)
40+
.mutation(async ({ input, ctx }): Promise<CertificateAuthority> => {
41+
return withErrorHandling(async () => {
42+
const pca = ctx.openstack?.service("clavis")
43+
validateOpenstackService(pca, "clavis")
44+
45+
const response = await pca.post(PCA_BASE_URL, { body: JSON.stringify(input) })
46+
const data = await response.json()
47+
48+
// Certificate Authority creation initiated successfully (async operation)
49+
return parseOrThrow(CertificateAuthorityResponseSchema, data, "pcaRouter.create").certificate_authority
50+
}, "create certificate authority")
51+
}),
3152
getById: projectScopedProcedure
3253
.input(CertificateAuthorityIdInputSchema)
3354
.query(async ({ input, ctx }): Promise<CertificateAuthority> => {
@@ -56,6 +77,20 @@ export const pcaRouter = {
5677
return parseOrThrow(CertificatesListSchema, data, "pcaRouter.listCertificates").certificates
5778
}, "list certificates for certificate authority")
5879
}),
80+
createCertificate: projectScopedProcedure
81+
.input(CreateCertificateInputSchema)
82+
.mutation(async ({ input, ctx }): Promise<Certificate> => {
83+
return withErrorHandling(async () => {
84+
const pca = ctx.openstack?.service("clavis")
85+
validateOpenstackService(pca, "clavis")
86+
87+
const url = `${PCA_BASE_URL}/${input.certificate_authority_id}/certificates`
88+
const response = await pca.post(url, { body: JSON.stringify(input.certificate) })
89+
const data = await response.json()
90+
91+
return parseOrThrow(CertificateResponseSchema, data, "pcaRouter.createCertificate").certificate
92+
}, "create certificate for certificate authority")
93+
}),
5994
getByIdCertificate: projectScopedProcedure
6095
.input(CertificateIdInputSchema)
6196
.query(async ({ input, ctx }): Promise<Certificate> => {

apps/aurora-portal/src/server/Services/types/pca.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { describe, it, expect } from "vitest"
22
import {
33
CertificateAuthoritiesListSchema,
4+
CertificateAuthorityCreateSchema,
45
CertificateAuthorityResponseSchema,
56
CertificateAuthoritySchema,
7+
CertificateConfigurationSchema,
68
CertificateAuthorityIdInputSchema,
79
CertificateIdInputSchema,
810
CertificateResponseSchema,
911
CertificateSchema,
1012
CertificatesListSchema,
13+
CreateCertificateInputSchema,
1114
} from "./pca"
1215
import { omit } from "@/server/helpers/object"
1316

@@ -770,6 +773,83 @@ describe("PCA (Private Certificate Authority) Schema Validation", () => {
770773
})
771774
})
772775

776+
describe("CertificateAuthorityCreateSchema", () => {
777+
it("should validate create input with subject.common_name", () => {
778+
expect(
779+
CertificateAuthorityCreateSchema.safeParse({
780+
configuration: {
781+
subject: { common_name: "ca.example.com" },
782+
},
783+
}).success
784+
).toBe(true)
785+
})
786+
787+
it("should reject create input without subject.common_name", () => {
788+
expect(
789+
CertificateAuthorityCreateSchema.safeParse({
790+
configuration: {
791+
subject: {},
792+
},
793+
}).success
794+
).toBe(false)
795+
})
796+
})
797+
798+
describe("CertificateConfigurationSchema", () => {
799+
it("should validate certificate configuration payload", () => {
800+
expect(
801+
CertificateConfigurationSchema.safeParse({
802+
validity: {
803+
not_after: 1736851200,
804+
not_before: 1705315200,
805+
},
806+
}).success
807+
).toBe(true)
808+
})
809+
810+
it("should reject certificate configuration payload without validity", () => {
811+
expect(CertificateConfigurationSchema.safeParse({}).success).toBe(false)
812+
})
813+
})
814+
815+
describe("CreateCertificateInputSchema", () => {
816+
it("should validate create certificate input", () => {
817+
expect(
818+
CreateCertificateInputSchema.safeParse({
819+
project_id: "project-1",
820+
certificate_authority_id: "ca-123",
821+
certificate: {
822+
configuration: {
823+
validity: {
824+
not_after: 1736851200,
825+
not_before: 1705315200,
826+
},
827+
},
828+
csr: "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----",
829+
},
830+
}).success
831+
).toBe(true)
832+
})
833+
834+
it("should reject create certificate input with empty certificate_authority_id", () => {
835+
expect(
836+
CreateCertificateInputSchema.safeParse({
837+
project_id: "project-1",
838+
certificate_authority_id: "",
839+
certificate: {
840+
configuration: {
841+
validity: {
842+
not_after: 1736851200,
843+
not_before: 1705315200,
844+
},
845+
},
846+
csr: "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----",
847+
},
848+
}).success
849+
).toBe(false)
850+
})
851+
})
852+
773853
describe("CertificateIdInputSchema", () => {
774854
it("should validate with required project_id, certificate_authority_id and certificate_id", () => {
775855
expect(

0 commit comments

Comments
 (0)