From c4308f88c2be407c5e04d2fc167651ca6b20c90c Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 31 Aug 2023 12:59:07 -0400 Subject: [PATCH 1/7] add component and test --- packages/api-v4/src/aglb/certificates.ts | 3 +- .../CreateCertificateDrawer.test.tsx | 31 +++++ .../Certificates/CreateCertificateDrawer.tsx | 130 ++++++++++++++++++ .../LoadBalancerCertificates.tsx | 14 +- .../manager/src/queries/aglb/certificates.ts | 25 +++- packages/validation/src/index.ts | 1 + .../validation/src/loadbalancers.schema.ts | 8 ++ 7 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.test.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx create mode 100644 packages/validation/src/loadbalancers.schema.ts diff --git a/packages/api-v4/src/aglb/certificates.ts b/packages/api-v4/src/aglb/certificates.ts index 8f5f7641eb9..1377b0b823e 100644 --- a/packages/api-v4/src/aglb/certificates.ts +++ b/packages/api-v4/src/aglb/certificates.ts @@ -8,6 +8,7 @@ import Request, { import { BETA_API_ROOT } from 'src/constants'; import { Filter, Params, ResourcePage } from '../types'; import { Certificate, CreateCertificatePayload } from './types'; +import { CreateCertificateSchema } from '@linode/validation'; /** * getLoadbalancerCertificates @@ -60,7 +61,7 @@ export const createLoadbalancerCertificate = ( `${BETA_API_ROOT}/aglb/${encodeURIComponent(loadbalancerId)}/certificates` ), setMethod('POST'), - setData(data) + setData(data, CreateCertificateSchema) ); /** diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.test.tsx new file mode 100644 index 00000000000..91e53f218e4 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.test.tsx @@ -0,0 +1,31 @@ +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CreateCertificateDrawer } from './CreateCertificateDrawer'; + +describe('CreateCertificateDrawer', () => { + it('should be submittable when form is filled out correctly', async () => { + const onClose = jest.fn(); + + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + const labelInput = getByLabelText('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/CreateCertificateDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx new file mode 100644 index 00000000000..109f23349a3 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx @@ -0,0 +1,130 @@ +import { CreateCertificatePayload } from '@linode/api-v4'; +import { Stack } from '@mui/material'; +import { useFormik } from 'formik'; +import React from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Notice } from 'src/components/Notice/Notice'; +import { Radio } from 'src/components/Radio/Radio'; +import { RadioGroup } from 'src/components/RadioGroup'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { useLoadBalancerCertificateCreateMutation } from 'src/queries/aglb/certificates'; +import { getErrorMap } from 'src/utilities/errorUtils'; + +interface Props { + loadbalancerId: number; + onClose: () => void; + open: boolean; +} + +export const CreateCertificateDrawer = (props: Props) => { + const { loadbalancerId, onClose: _onClose, open } = props; + + const onClose = () => { + formik.resetForm(); + _onClose(); + reset(); + }; + + const { + error, + mutateAsync: createCertificate, + reset, + } = useLoadBalancerCertificateCreateMutation(loadbalancerId); + + const formik = useFormik({ + initialValues: { + certificate: '', + key: '', + label: '', + type: 'downstream', + }, + async onSubmit(values) { + await createCertificate(values); + onClose(); + }, + }); + + const errorMap = getErrorMap(['label', 'key', 'certificate'], error); + + return ( + +
+ {errorMap.none && } + + Upload the certificates for Load Balancer authentication. + + + + TLS Certificate + + Used by your load balancer to terminate the connection and + decrypt request from client prior to sending the request to + the endpoints in your Service Targets. You can specify a Host + Header. Also referred to as SSL Certificate. + + + } + control={} + value="downstream" + /> + + Service Target Certificate + + Used by the load balancer to accept responses from your + endpoints in your Service Target. This is the certificate + installed on your Endpoints. + + + } + control={} + sx={{ mt: 2 }} + value="ca" + /> + + + + + + +
+ ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerCertificates.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerCertificates.tsx index 6c5e92f3a7f..294dcd33779 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerCertificates.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerCertificates.tsx @@ -25,6 +25,7 @@ import { usePagination } from 'src/hooks/usePagination'; import { useLoadBalancerCertificatesQuery } from 'src/queries/aglb/certificates'; import type { Certificate, Filter } from '@linode/api-v4'; +import { CreateCertificateDrawer } from './Certificates/CreateCertificateDrawer'; const PREFERENCE_KEY = 'loadbalancer-certificates'; @@ -38,6 +39,7 @@ type CertificateTypeFilter = 'all' | Certificate['type']; export const LoadBalancerCertificates = () => { const { loadbalancerId } = useParams<{ loadbalancerId: string }>(); + const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); const [type, setType] = useState('all'); const [query, setQuery] = useState(); @@ -134,7 +136,12 @@ export const LoadBalancerCertificates = () => { value={query} /> - + @@ -185,6 +192,11 @@ export const LoadBalancerCertificates = () => { page={pagination.page} pageSize={pagination.pageSize} /> + setIsCreateDrawerOpen(false)} + open={isCreateDrawerOpen} + /> ); }; diff --git a/packages/manager/src/queries/aglb/certificates.ts b/packages/manager/src/queries/aglb/certificates.ts index 716b1276946..46d9d824116 100644 --- a/packages/manager/src/queries/aglb/certificates.ts +++ b/packages/manager/src/queries/aglb/certificates.ts @@ -1,11 +1,15 @@ -import { getLoadbalancerCertificates } from '@linode/api-v4'; -import { useQuery } from 'react-query'; +import { + createLoadbalancerCertificate, + getLoadbalancerCertificates, +} from '@linode/api-v4'; +import { useQuery, useQueryClient, useMutation } from 'react-query'; import { QUERY_KEY } from './loadbalancers'; import type { APIError, Certificate, + CreateCertificatePayload, Filter, Params, ResourcePage, @@ -22,3 +26,20 @@ export const useLoadBalancerCertificatesQuery = ( { keepPreviousData: true } ); }; + +export const useLoadBalancerCertificateCreateMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation( + (data) => createLoadbalancerCertificate(id, data), + { + onSuccess() { + queryClient.invalidateQueries([ + QUERY_KEY, + 'loadbalancer', + id, + 'certificates', + ]); + }, + } + ); +}; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index c0beba2bcbb..92bd107f56c 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -6,6 +6,7 @@ export * from './firewalls.schema'; export * from './images.schema'; export * from './kubernetes.schema'; export * from './linodes.schema'; +export * from './loadbalancers.schema'; export * from './longview.schema'; export * from './managed.schema'; export * from './networking.schema'; diff --git a/packages/validation/src/loadbalancers.schema.ts b/packages/validation/src/loadbalancers.schema.ts new file mode 100644 index 00000000000..6b0e348323a --- /dev/null +++ b/packages/validation/src/loadbalancers.schema.ts @@ -0,0 +1,8 @@ +import { object, string } from 'yup'; + +export const CreateCertificateSchema = object({ + certificate: string().required(), + key: string(), + label: string().required(), + type: string().oneOf(['downstream', 'ca']).required(), +}); From f78630658b0718c851caded2f6a9c3f7722131e3 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 31 Aug 2023 14:23:35 -0400 Subject: [PATCH 2/7] do goofy radio styling --- .../Certificates/CreateCertificateDrawer.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx index 109f23349a3..237d8a52058 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx @@ -64,7 +64,7 @@ export const CreateCertificateDrawer = (props: Props) => { > + TLS Certificate Used by your load balancer to terminate the connection and @@ -75,11 +75,12 @@ export const CreateCertificateDrawer = (props: Props) => { } control={} + sx={{ alignItems: 'flex-start' }} value="downstream" /> + Service Target Certificate Used by the load balancer to accept responses from your @@ -89,7 +90,7 @@ export const CreateCertificateDrawer = (props: Props) => { } control={} - sx={{ mt: 2 }} + sx={{ alignItems: 'flex-start', mt: 2 }} value="ca" /> From 9a492133514f101133adcc714ae0819e51e565cc Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 31 Aug 2023 14:29:52 -0400 Subject: [PATCH 3/7] improve validation --- packages/validation/src/loadbalancers.schema.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/validation/src/loadbalancers.schema.ts b/packages/validation/src/loadbalancers.schema.ts index 6b0e348323a..ad10ec0cb0a 100644 --- a/packages/validation/src/loadbalancers.schema.ts +++ b/packages/validation/src/loadbalancers.schema.ts @@ -1,8 +1,11 @@ import { object, string } from 'yup'; export const CreateCertificateSchema = object({ - certificate: string().required(), - key: string(), - label: string().required(), - type: string().oneOf(['downstream', 'ca']).required(), + certificate: string().required('Certificate is required.'), + key: string().when('type', { + is: 'downstream', + then: string().required('Private Key is required.'), + }), + label: string().required('Label is required.'), + type: string().oneOf(['downstream', 'ca']).required('Type is required.'), }); From 6c0fa5c6cb0866c36c6c09cf278ffec8f01886fb Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 31 Aug 2023 14:37:05 -0400 Subject: [PATCH 4/7] Added changeset: Add AGLB Certificate Create Drawer --- .../.changeset/pr-9616-upcoming-features-1693507025003.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-9616-upcoming-features-1693507025003.md diff --git a/packages/manager/.changeset/pr-9616-upcoming-features-1693507025003.md b/packages/manager/.changeset/pr-9616-upcoming-features-1693507025003.md new file mode 100644 index 00000000000..abc4ccb30eb --- /dev/null +++ b/packages/manager/.changeset/pr-9616-upcoming-features-1693507025003.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add AGLB Certificate Create Drawer ([#9616](https://github.com/linode/manager/pull/9616)) From 0380e5cbf42aef6ea3845ae6aaa2c2a56e0cdd23 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 1 Sep 2023 09:32:20 -0400 Subject: [PATCH 5/7] fix test --- .../LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx index 237d8a52058..894a32fafba 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx @@ -116,6 +116,7 @@ export const CreateCertificateDrawer = (props: Props) => { labelTooltipText="TODO" multiline name="key" + onChange={formik.handleChange} value={formik.values.key} /> Date: Wed, 6 Sep 2023 12:42:08 -0400 Subject: [PATCH 6/7] add `trimmed` prop to SSH Key TextFields --- .../LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx index 894a32fafba..9f2d91825b3 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx @@ -108,6 +108,7 @@ export const CreateCertificateDrawer = (props: Props) => { multiline name="certificate" onChange={formik.handleChange} + trimmed value={formik.values.certificate} /> { multiline name="key" onChange={formik.handleChange} + trimmed value={formik.values.key} /> Date: Thu, 7 Sep 2023 15:03:32 -0400 Subject: [PATCH 7/7] Add AGLB cert upload integration tests and related utils --- .../load-balancer-details-page.spec.ts | 148 ++++++++++++++++++ .../support/intercepts/load-balancers.ts | 54 +++++++ .../Certificates/CreateCertificateDrawer.tsx | 4 +- 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 packages/manager/cypress/e2e/core/loadBalancers/load-balancer-details-page.spec.ts diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-details-page.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-details-page.spec.ts new file mode 100644 index 00000000000..261d3bdc01f --- /dev/null +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-details-page.spec.ts @@ -0,0 +1,148 @@ +/** + * @file Integration tests for Akamai Global Load Balancer details page. + */ + +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { loadbalancerFactory, certificateFactory } from '@src/factories'; +import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; +import { + mockGetLoadBalancer, + mockGetLoadBalancerCertificates, + mockUploadLoadBalancerCertificate, +} from 'support/intercepts/load-balancers'; + +/** + * Uploads a Load Balancer certificate using the "Upload Certificate" drawer. + * + * This function assumes the "Upload Certificate" drawer is already open. + * + * @param type - Certificate type; either 'tls' or 'service-target'. + * @param label - Certificate label. + */ +const uploadCertificate = (type: 'tls' | 'service-target', label: string) => { + const radioSelector = + type === 'tls' ? '[data-qa-cert-tls]' : '[data-qa-cert-service-target]'; + + ui.drawer + .findByTitle('Upload Certificate') + .should('be.visible') + .within(() => { + cy.get(radioSelector).should('be.visible').click(); + + cy.findByLabelText('Label').should('be.visible').type(label); + + cy.findByLabelText('TLS Certificate') + .should('be.visible') + .type(randomString(32)); + + cy.findByLabelText('Private Key') + .should('be.visible') + .type(randomString(32)); + + ui.buttonGroup + .findButtonByTitle('Upload Certificate') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); +}; + +describe('Akamai Global Load Balancer details page', () => { + /* + * - Confirms Load Balancer certificate upload UI flow using mocked API requests. + * - Confirms that TLS and Service Target certificates can be uploaded. + * - Confirms that certificates table update to reflects uploaded certificates. + */ + it('can upload a Load Balancer Certificate', () => { + const mockLoadBalancer = loadbalancerFactory.build(); + const mockLoadBalancerCertTls = certificateFactory.build({ + label: randomLabel(), + type: 'downstream', + }); + const mockLoadBalancerCertServiceTarget = certificateFactory.build({ + label: randomLabel(), + type: 'ca', + }); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetLoadBalancerCertificates(mockLoadBalancer.id, []).as( + 'getCertificates' + ); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getCertificates', + ]); + + // Confirm that no certificates are listed. + cy.findByText('No items to display.').should('be.visible'); + + // Upload a TLS certificate. + ui.button + .findByTitle('Upload Certificate') + .should('be.visible') + .should('be.enabled') + .click(); + + mockUploadLoadBalancerCertificate( + mockLoadBalancer.id, + mockLoadBalancerCertTls + ).as('uploadCertificate'); + mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ + mockLoadBalancerCertTls, + ]).as('getCertificates'); + uploadCertificate('tls', mockLoadBalancerCertTls.label); + + // Confirm that new certificate is listed in the table with expected info. + cy.wait(['@uploadCertificate', '@getCertificates']); + cy.findByText(mockLoadBalancerCertTls.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('TLS Certificate').should('be.visible'); + }); + + ui.button + .findByTitle('Upload Certificate') + .should('be.visible') + .should('be.enabled') + .click(); + + // Upload a service target certificate. + mockUploadLoadBalancerCertificate( + mockLoadBalancer.id, + mockLoadBalancerCertServiceTarget + ).as('uploadCertificate'); + mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ + mockLoadBalancerCertTls, + mockLoadBalancerCertServiceTarget, + ]).as('getCertificates'); + uploadCertificate( + 'service-target', + mockLoadBalancerCertServiceTarget.label + ); + + // Confirm that both new certificates are listed in the table with expected info. + cy.wait(['@uploadCertificate', '@getCertificates']); + cy.findByText(mockLoadBalancerCertTls.label).should('be.visible'); + cy.findByText(mockLoadBalancerCertServiceTarget.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Service Target Certificate').should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/load-balancers.ts b/packages/manager/cypress/support/intercepts/load-balancers.ts index c5a9f956f82..458dffd2366 100644 --- a/packages/manager/cypress/support/intercepts/load-balancers.ts +++ b/packages/manager/cypress/support/intercepts/load-balancers.ts @@ -5,7 +5,24 @@ import type { ServiceTarget, Loadbalancer, Configuration, + Certificate, } from '@linode/api-v4'; +import { makeResponse } from 'support/util/response'; + +/** + * Intercepts GET request to fetch an AGLB load balancer and mocks response. + * + * @param loadBalancer - Load balancer with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetLoadBalancer = (loadBalancer: Loadbalancer) => { + return cy.intercept( + 'GET', + apiMatcher(`/aglb/${loadBalancer.id}`), + makeResponse(loadBalancer) + ); +}; /** * Intercepts GET request to retrieve AGLB load balancers and mocks response. @@ -41,6 +58,43 @@ export const mockGetLoadBalancerConfigurations = ( ); }; +/** + * Intercepts GET requests to retrieve AGLB load balancer certificates and mocks response. + * + * @param loadBalancerId - ID of load balancer for which to mock certificates. + * @param certificates - Load balancer certificates with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetLoadBalancerCertificates = ( + loadBalancerId: number, + certificates: Certificate[] +) => { + return cy.intercept( + 'GET', + apiMatcher(`/aglb/${loadBalancerId}/certificates*`), + paginateResponse(certificates) + ); +}; + +/** + * Intercepts POST request to upload an AGLB load balancer certificate and mocks a success response. + * + * @param loadBalancerId - ID of load balancer for which to mock certificates. + * + * @returns Cypress chainable. + */ +export const mockUploadLoadBalancerCertificate = ( + loadBalancerId: number, + certificate: Certificate +) => { + return cy.intercept( + 'POST', + apiMatcher(`/aglb/${loadBalancerId}/certificates`), + makeResponse(certificate) + ); +}; + /** * Intercepts GET request to retrieve AGLB service targets and mocks response. * diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx index 9f2d91825b3..05dda8fb354 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx @@ -74,7 +74,7 @@ export const CreateCertificateDrawer = (props: Props) => { } - control={} + control={} sx={{ alignItems: 'flex-start' }} value="downstream" /> @@ -89,7 +89,7 @@ export const CreateCertificateDrawer = (props: Props) => { } - control={} + control={} sx={{ alignItems: 'flex-start', mt: 2 }} value="ca" />