diff --git a/packages/manager/.changeset/pr-9880-tests-1699311124062.md b/packages/manager/.changeset/pr-9880-tests-1699311124062.md new file mode 100644 index 00000000000..db416179f58 --- /dev/null +++ b/packages/manager/.changeset/pr-9880-tests-1699311124062.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add integration tests for AGLB Edit Certificate flow ([#9880](https://github.com/linode/manager/pull/9880)) diff --git a/packages/manager/.changeset/pr-9880-upcoming-features-1699311064702.md b/packages/manager/.changeset/pr-9880-upcoming-features-1699311064702.md new file mode 100644 index 00000000000..c2f285eb304 --- /dev/null +++ b/packages/manager/.changeset/pr-9880-upcoming-features-1699311064702.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add missing label field validation in AGLB Edit Certificate drawer ([#9880](https://github.com/linode/manager/pull/9880)) 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 ed7cbe40634..ddd7bfe51e5 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 @@ -7,7 +7,11 @@ import { mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { loadbalancerFactory, certificateFactory } from '@src/factories'; +import { + loadbalancerFactory, + certificateFactory, + mockCertificate, +} from '@src/factories'; import { ui } from 'support/ui'; import { randomItem, randomLabel, randomString } from 'support/util/random'; import { @@ -15,6 +19,7 @@ import { mockDeleteLoadBalancerCertificateError, mockGetLoadBalancer, mockGetLoadBalancerCertificates, + mockUpdateLoadBalancerCertificate, mockUploadLoadBalancerCertificate, } from 'support/intercepts/load-balancers'; import { Loadbalancer, Certificate } from '@linode/api-v4/types'; @@ -241,6 +246,221 @@ describe('Akamai Global Load Balancer certificates page', () => { cy.findByText(mockLoadBalancerCertServiceTarget.label).should('be.visible'); }); + /* + * - Confirms Load Balancer certificate edit UI flow using mocked API requests. + * - Confirms that TLS and Service Target certificates can be edited. + * - Confirms that certificates table updates to reflect edited certificates. + */ + it('can update a TLS certificate', () => { + const mockLoadBalancer = loadbalancerFactory.build(); + const mockLoadBalancerCertTls = certificateFactory.build({ + label: randomLabel(), + type: 'downstream', + certificate: mockCertificate.trim(), + }); + const mockNewLoadBalancerCertTls = certificateFactory.build({ + label: 'my-updated-tls-cert', + certificate: 'mock-new-cert', + key: 'mock-new-key', + type: 'downstream', + }); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetLoadBalancerCertificates( + mockLoadBalancer.id, + mockLoadBalancerCertTls + ).as('getCertificates'); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getCertificates', + ]); + + // Edit a TLS certificate. + ui.actionMenu + .findByTitle( + `Action Menu for certificate ${mockLoadBalancerCertTls.label}` + ) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + + mockUpdateLoadBalancerCertificate( + mockLoadBalancer.id, + mockLoadBalancerCertTls + ).as('updateCertificate'); + + mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ + mockNewLoadBalancerCertTls, + ]).as('getCertificates'); + + ui.drawer + .findByTitle(`Edit ${mockLoadBalancerCertTls.label}`) + .should('be.visible') + .within(() => { + // Confirm that drawer displays certificate data or indicates where data is redacted for security. + cy.findByLabelText('Certificate Label') + .should('be.visible') + .should('have.value', mockLoadBalancerCertTls.label); + + cy.findByLabelText('TLS Certificate') + .should('be.visible') + .should('have.value', mockLoadBalancerCertTls.certificate); + + cy.findByLabelText('Private Key') + .should('be.visible') + .should('have.value', '') + .invoke('attr', 'placeholder') + .should('contain', 'Private key is redacted for security.'); + + // Attempt to submit an incorrect form without a label or a new cert key. + cy.findByLabelText('Certificate Label').clear(); + cy.findByLabelText('TLS Certificate').clear().type('my-new-cert'); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that validation errors appear when drawer is not filled out correctly. + cy.findAllByText('Label must not be empty.').should('be.visible'); + cy.findAllByText('Private Key is required').should('be.visible'); + + // Fix errors. + cy.findByLabelText('Certificate Label') + .click() + .type(mockNewLoadBalancerCertTls.label); + + cy.findByLabelText('TLS Certificate') + .click() + .type(mockNewLoadBalancerCertTls.certificate); + + cy.findByLabelText('Private Key') + .click() + .type(mockNewLoadBalancerCertTls.key); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .click(); + }); + + cy.wait(['@updateCertificate', '@getCertificates']); + + // Confirm that new certificate is listed in the table with expected info. + cy.findByText(mockNewLoadBalancerCertTls.label).should('be.visible'); + }); + + it('can update a service target certificate', () => { + const mockLoadBalancer = loadbalancerFactory.build(); + const mockLoadBalancerCertServiceTarget = certificateFactory.build({ + label: randomLabel(), + type: 'ca', + certificate: mockCertificate.trim(), + }); + const mockNewLoadBalancerCertServiceTarget = certificateFactory.build({ + label: 'my-updated-ca-cert', + certificate: 'mock-new-cert', + type: 'ca', + }); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetLoadBalancerCertificates( + mockLoadBalancer.id, + mockLoadBalancerCertServiceTarget + ).as('getCertificates'); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getCertificates', + ]); + + // Edit a CA certificate. + ui.actionMenu + .findByTitle( + `Action Menu for certificate ${mockLoadBalancerCertServiceTarget.label}` + ) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + + mockUpdateLoadBalancerCertificate( + mockLoadBalancer.id, + mockLoadBalancerCertServiceTarget + ).as('updateCertificate'); + + mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ + mockNewLoadBalancerCertServiceTarget, + ]).as('getCertificates'); + + ui.drawer + .findByTitle(`Edit ${mockLoadBalancerCertServiceTarget.label}`) + .should('be.visible') + .within(() => { + // Confirm that drawer displays certificate data or indicates where data is redacted for security. + cy.findByLabelText('Certificate Label') + .should('be.visible') + .should('have.value', mockLoadBalancerCertServiceTarget.label); + + cy.findByLabelText('Server Certificate') + .should('be.visible') + .should('have.value', mockLoadBalancerCertServiceTarget.certificate); + + cy.findByLabelText('Private Key').should('not.exist'); + + // Attempt to submit an incorrect form without a label. + cy.findByLabelText('Certificate Label').clear(); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that validation error appears when drawer is not filled out correctly. + cy.findAllByText('Label must not be empty.').should('be.visible'); + + // Fix error. + cy.findByLabelText('Certificate Label') + .click() + .type(mockNewLoadBalancerCertServiceTarget.label); + + // Update certificate. + cy.findByLabelText('Server Certificate') + .click() + .type(mockNewLoadBalancerCertServiceTarget.certificate); + + ui.buttonGroup + .findButtonByTitle('Update Certificate') + .scrollIntoView() + .click(); + }); + + cy.wait(['@updateCertificate', '@getCertificates']); + + // Confirm that new certificate is listed in the table with expected info. + cy.findByText(mockNewLoadBalancerCertServiceTarget.label).should( + 'be.visible' + ); + }); + /* * - Confirms Load Balancer certificate delete UI flow using mocked API requests. * - Confirms that TLS and Service Target certificates can be deleted. diff --git a/packages/manager/cypress/support/intercepts/load-balancers.ts b/packages/manager/cypress/support/intercepts/load-balancers.ts index 19cf0a5b884..c79b257c2db 100644 --- a/packages/manager/cypress/support/intercepts/load-balancers.ts +++ b/packages/manager/cypress/support/intercepts/load-balancers.ts @@ -139,6 +139,24 @@ export const mockDeleteLoadBalancerCertificateError = ( ); }; +/** + * Intercepts PUT request to update 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 mockUpdateLoadBalancerCertificate = ( + loadBalancerId: number, + certificate: Certificate +) => { + return cy.intercept( + 'PUT', + apiMatcher(`/aglb/${loadBalancerId}/certificates/${certificate.id}`), + 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 d5521bcaf5a..87e113a9ee0 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CreateCertificateDrawer.tsx @@ -7,7 +7,8 @@ import { Notice } from 'src/components/Notice/Notice'; 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'; +import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; +import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { labelMap } from './EditCertificateDrawer'; @@ -66,33 +67,38 @@ export const CreateCertificateDrawer = (props: Props) => { type, }, async onSubmit(values) { - await createCertificate(values); - onClose(); + try { + await createCertificate(values); + onClose(); + } catch (errors) { + formik.setErrors(getFormikErrorsFromAPIErrors(errors)); + scrollErrorIntoView(); + } }, + // Disabling validateOnBlur and validateOnChange when an API error is shown prevents + // all API errors from disappearing when one field is changed. + validateOnBlur: !error, + validateOnChange: !error, }); - const errorFields = ['label', 'certificate']; - - if (type === 'downstream') { - errorFields.push('key'); - } - - const errorMap = getErrorMap(errorFields, error); + const generalError = error?.find((e) => !e.field)?.reason; return ( - +
- {errorMap.none && } + {generalError && } {descriptionMap[type]} { /> {type === 'downstream' && ( { 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(); + try { + await updateCertificate({ + certificate: + values.certificate && !shouldIgnoreField + ? values.certificate + : undefined, + key: values.key && !shouldIgnoreField ? values.key : undefined, + label: values.label, + type: values.type, + }); + onClose(); + } catch (errors) { + formik.setErrors(getFormikErrorsFromAPIErrors(errors)); + scrollErrorIntoView(); + } }, + // Disabling validateOnBlur and validateOnChange when an API error is shown prevents + // all API errors from disappearing when one field is changed. + validateOnBlur: !error, + validateOnChange: !error, }); - const errorFields = ['label', 'certificate']; - - if (certificate?.type === 'downstream') { - errorFields.push('key'); - } - - const errorMap = getErrorMap(errorFields, error); + const generalError = error?.find((e) => !e.field)?.reason; const onClose = () => { formik.resetForm(); @@ -89,17 +93,16 @@ export const EditCertificateDrawer = (props: Props) => { title={`Edit ${certificate?.label ?? 'Certificate'}`} wide > - {errorMap.none && {errorMap.none}} + {generalError && {generalError}} {!certificate ? ( Error loading certificate. ) : ( - {errorMap.none && } {descriptionMap[certificate.type]} { value={formik.values.label} /> { /> {certificate?.type === 'downstream' && (