diff --git a/packages/api-v4/src/aglb/certificates.ts b/packages/api-v4/src/aglb/certificates.ts index c3f2a2571f1..5e8d2afb102 100644 --- a/packages/api-v4/src/aglb/certificates.ts +++ b/packages/api-v4/src/aglb/certificates.ts @@ -7,8 +7,15 @@ import Request, { } from '../request'; import { BETA_API_ROOT } from '../constants'; import { Filter, Params, ResourcePage } from '../types'; -import { Certificate, CreateCertificatePayload } from './types'; -import { CreateCertificateSchema } from '@linode/validation'; +import { + Certificate, + CreateCertificatePayload, + UpdateCertificatePayload, +} from './types'; +import { + CreateCertificateSchema, + UpdateCertificateSchema, +} from '@linode/validation'; /** * getLoadbalancerCertificates @@ -67,12 +74,12 @@ export const createLoadbalancerCertificate = ( /** * updateLoadbalancerCertificate * - * Creates an Akamai Global Load Balancer certificate + * Updates an Akamai Global Load Balancer certificate */ export const updateLoadbalancerCertificate = ( loadbalancerId: number, certificateId: number, - data: Partial + data: Partial ) => Request( setURL( @@ -81,7 +88,7 @@ export const updateLoadbalancerCertificate = ( )}/certificates/${encodeURIComponent(certificateId)}` ), setMethod('PUT'), - setData(data) + setData(data, UpdateCertificateSchema) ); /** diff --git a/packages/api-v4/src/aglb/types.ts b/packages/api-v4/src/aglb/types.ts index 2bbc30245bf..da38aa56885 100644 --- a/packages/api-v4/src/aglb/types.ts +++ b/packages/api-v4/src/aglb/types.ts @@ -160,6 +160,7 @@ type CertificateType = 'ca' | 'downstream'; export interface Certificate { id: number; label: string; + certificate: string; type: CertificateType; } @@ -169,3 +170,10 @@ export interface CreateCertificatePayload { label: string; type: CertificateType; } + +export interface UpdateCertificatePayload { + key?: string; + certificate?: string; + label?: string; + type?: CertificateType; +} diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts index 70993968a10..f943313d483 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts @@ -145,7 +145,7 @@ describe('Akamai Global Load Balancer certificates page', () => { .should('be.visible') .type(mockLoadBalancerCertServiceTarget.label); - cy.findByLabelText('TLS Certificate') + cy.findByLabelText('Server Certificate') .should('be.visible') .type(randomString(32)); diff --git a/packages/manager/src/factories/aglb.ts b/packages/manager/src/factories/aglb.ts index d5702baed5b..6016b26c92d 100644 --- a/packages/manager/src/factories/aglb.ts +++ b/packages/manager/src/factories/aglb.ts @@ -14,7 +14,7 @@ import { import * as Factory from 'factory.ts'; import { pickRandom } from 'src/utilities/random'; -const certificate = ` +export const mockCertificate = ` -----BEGIN CERTIFICATE----- MIID0DCCArigAwIBAgIBATANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJGUjET MBEGA1UECAwKU29tZS1TdGF0ZTEOMAwGA1UEBwwFUGFyaXMxDTALBgNVBAoMBERp @@ -40,7 +40,7 @@ cbTV5RDkrlaYwm5yqlTIglvCv7o= -----END CERTIFICATE----- `; -const key = ` +const mockKey = ` -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAvpnaPKLIKdvx98KW68lz8pGaRRcYersNGqPjpifMVjjE8LuC oXgPU0HePnNTUjpShBnynKCvrtWhN+haKbSp+QWXSxiTrW99HBfAl1MDQyWcukoE @@ -275,6 +275,7 @@ export const createServiceTargetFactory = Factory.Sync.makeFactory({ + certificate: mockCertificate, id: Factory.each((i) => i), label: Factory.each((i) => `certificate-${i}`), type: 'ca', @@ -282,8 +283,8 @@ export const certificateFactory = Factory.Sync.makeFactory({ export const createCertificateFactory = Factory.Sync.makeFactory( { - certificate, - key, + certificate: mockCertificate, + key: mockKey, label: 'my-cert', type: 'downstream', } diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/Certificates.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/Certificates.tsx index 6f393aa7063..819f9ddb2d9 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/Certificates.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/Certificates.tsx @@ -22,6 +22,7 @@ import { useLoadBalancerCertificatesQuery } from 'src/queries/aglb/certificates' import { CreateCertificateDrawer } from './CreateCertificateDrawer'; import { DeleteCertificateDialog } from './DeleteCertificateDialog'; +import { EditCertificateDrawer } from './EditCertificateDrawer'; import type { Certificate, Filter } from '@linode/api-v4'; @@ -40,6 +41,7 @@ export const Certificates = () => { const history = useHistory(); const isCreateDrawerOpen = location.pathname.endsWith('/create'); + const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false); const [isDeleteDrawerOpen, setIsDeleteDrawerOpen] = useState(false); const [selectedCertificateId, setSelectedCertificateId] = useState(); @@ -69,6 +71,11 @@ export const Certificates = () => { filter ); + const onEditCertificate = (certificate: Certificate) => { + setIsEditDrawerOpen(true); + setSelectedCertificateId(certificate.id); + }; + const onDeleteCertificate = (certificate: Certificate) => { setIsDeleteDrawerOpen(true); setSelectedCertificateId(certificate.id); @@ -147,7 +154,10 @@ export const Certificates = () => { null, title: 'Edit' }, + { + onClick: () => onEditCertificate(certificate), + title: 'Edit', + }, { onClick: () => onDeleteCertificate(certificate), title: 'Delete', @@ -175,6 +185,12 @@ export const Certificates = () => { open={isCreateDrawerOpen} type={certType} /> + setIsEditDrawerOpen(false)} + open={isEditDrawerOpen} + /> { /> { + it('should contain the name of the cert in the drawer title and label field', () => { + const onClose = jest.fn(); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + const labelInput = getByLabelText('Certificate Label'); + + expect(getByTestId('drawer-title')).toHaveTextContent( + `Edit ${mockTLSCertificate.label}` + ); + expect(labelInput).toHaveDisplayValue(mockTLSCertificate.label); + }); + + it('should contain the cert in the cert field and placeholder text in the private key for a downstream cert', () => { + const onClose = jest.fn(); + + const { getByLabelText } = renderWithTheme( + + ); + + const certInput = getByLabelText('TLS Certificate'); + const keyInput = getByLabelText('Private Key'); + + expect(certInput).toHaveDisplayValue(mockTLSCertificate.certificate.trim()); + expect(keyInput).toHaveAttribute( + 'placeholder', + 'Private key is redacted for security.' + ); + }); + + it('should submit and close drawer when only the label of the certificate is edited', async () => { + const onClose = jest.fn(); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + const labelInput = getByLabelText('Certificate Label'); + const certInput = getByLabelText('Server Certificate'); + + expect(labelInput).toHaveDisplayValue(mockCACertificate.label); + expect(certInput).toHaveDisplayValue(mockCACertificate.certificate.trim()); + + act(() => { + userEvent.type(labelInput, 'my-updated-cert-0'); + userEvent.click(getByTestId('submit')); + }); + + await waitFor(() => expect(onClose).toBeCalled()); + }); + + it('should submit and close drawer when both a certificate and key are included', async () => { + const onClose = jest.fn(); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + const labelInput = getByLabelText('Certificate Label'); + const certInput = getByLabelText('TLS Certificate'); + const keyInput = getByLabelText('Private Key'); + + act(() => { + userEvent.type(labelInput, 'my-cert-0'); + userEvent.type(certInput, 'massive cert'); + userEvent.type(keyInput, 'massive key'); + + userEvent.click(getByTestId('submit')); + }); + + await waitFor(() => expect(onClose).toBeCalled()); + }); +}); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/EditCertificateDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/EditCertificateDrawer.tsx new file mode 100644 index 00000000000..768e6331a56 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/EditCertificateDrawer.tsx @@ -0,0 +1,145 @@ +import { Certificate, UpdateCertificatePayload } from '@linode/api-v4'; +import { useTheme } from '@mui/material/styles'; +import { useFormik } from 'formik'; +import React from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { useLoadBalancerCertificateMutation } from 'src/queries/aglb/certificates'; +import { getErrorMap } from 'src/utilities/errorUtils'; + +interface Props { + certificate: Certificate | undefined; + loadbalancerId: number; + onClose: () => void; + open: boolean; +} + +export const labelMap: Record = { + ca: 'Server Certificate', + downstream: 'TLS Certificate', +}; + +/* TODO: AGLB - Update with final copy. */ +const descriptionMap: Record = { + ca: 'You can edit this cert here. Maybe something about service targets.', + downstream: + 'You can edit this cert here. Perhaps something about the private key and the hos header and what it does.', +}; + +export const EditCertificateDrawer = (props: Props) => { + const { certificate, loadbalancerId, onClose: _onClose, open } = props; + + const theme = useTheme(); + + const { + error, + mutateAsync: updateCertificate, + reset, + } = useLoadBalancerCertificateMutation(loadbalancerId, certificate?.id ?? -1); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + certificate: certificate?.certificate.trim(), + key: '', + label: certificate?.label ?? '', + type: certificate?.type, + }, + async onSubmit(values) { + // The user has not edited their cert or the private key, so we exclude both cert and key from the request. + const shouldIgnoreField = + certificate?.certificate.trim() === values.certificate && + values.key === ''; + + await updateCertificate({ + certificate: + values.certificate && !shouldIgnoreField + ? values.certificate + : undefined, + key: values.key && !shouldIgnoreField ? values.key : undefined, + label: values.label, + type: values.type, + }); + onClose(); + }, + }); + + const errorFields = ['label', 'certificate']; + + if (certificate?.type === 'downstream') { + errorFields.push('key'); + } + + const errorMap = getErrorMap(errorFields, error); + + const onClose = () => { + formik.resetForm(); + _onClose(); + reset(); + }; + + return ( + + {errorMap.none && {errorMap.none}} + {!certificate ? ( + Error loading certificate. + ) : ( +
+ {errorMap.none && } + + {descriptionMap[certificate.type]} + + + + {certificate?.type === 'downstream' && ( + + )} + + + )} +
+ ); +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 67a101b65e1..ff187cb740b 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -391,8 +391,12 @@ const aglb = [ ), // Certificates rest.get('*/v4beta/aglb/:id/certificates', (req, res, ctx) => { + const tlsCertificate = certificateFactory.build({ + label: 'tls-certificate', + type: 'downstream', + }); const certificates = certificateFactory.buildList(3); - return res(ctx.json(makeResourcePage(certificates))); + return res(ctx.json(makeResourcePage([tlsCertificate, ...certificates]))); }), rest.post('*/v4beta/aglb/:id/certificates', (req, res, ctx) => { return res(ctx.json(certificateFactory.build())); diff --git a/packages/manager/src/queries/aglb/certificates.ts b/packages/manager/src/queries/aglb/certificates.ts index 39ab4168d82..be5f1d0e905 100644 --- a/packages/manager/src/queries/aglb/certificates.ts +++ b/packages/manager/src/queries/aglb/certificates.ts @@ -2,6 +2,7 @@ import { createLoadbalancerCertificate, deleteLoadbalancerCertificate, getLoadbalancerCertificates, + updateLoadbalancerCertificate, } from '@linode/api-v4'; import { useInfiniteQuery, @@ -19,6 +20,7 @@ import type { Filter, Params, ResourcePage, + UpdateCertificatePayload, } from '@linode/api-v4'; export const useLoadBalancerCertificatesQuery = ( @@ -60,6 +62,27 @@ export const useLoadBalancerCertificateCreateMutation = ( ); }; +export const useLoadBalancerCertificateMutation = ( + loadbalancerId: number, + certificateId: number +) => { + const queryClient = useQueryClient(); + return useMutation( + (data) => + updateLoadbalancerCertificate(loadbalancerId, certificateId, data), + { + onSuccess() { + queryClient.invalidateQueries([ + QUERY_KEY, + 'loadbalancer', + loadbalancerId, + 'certificates', + ]); + }, + } + ); +}; + export const useLoadBalancerCertificateDeleteMutation = ( loadbalancerId: number, certificateId: number diff --git a/packages/validation/src/loadbalancers.schema.ts b/packages/validation/src/loadbalancers.schema.ts index e2a5f091042..a5d36d1fdcb 100644 --- a/packages/validation/src/loadbalancers.schema.ts +++ b/packages/validation/src/loadbalancers.schema.ts @@ -10,6 +10,20 @@ export const CreateCertificateSchema = object({ type: string().oneOf(['downstream', 'ca']).required('Type is required.'), }); +export const UpdateCertificateSchema = object().shape( + { + certificate: string(), + key: string().when(['type', 'certificate'], { + is: (type: string, certificate: string) => + type === 'downstream' && certificate, + then: string().required('Private Key is required'), + }), + label: string(), + type: string().oneOf(['downstream', 'ca']), + }, + [['certificate', 'key']] +); + export const certificateConfigSchema = object({ certificates: array( object({