diff --git a/packages/api-v4/.changeset/pr-13274-changed-1768306424456.md b/packages/api-v4/.changeset/pr-13274-changed-1768306424456.md new file mode 100644 index 00000000000..18167c5293b --- /dev/null +++ b/packages/api-v4/.changeset/pr-13274-changed-1768306424456.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Adjust Custom HTTPS Destination types ([#13274](https://github.com/linode/manager/pull/13274)) diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index cb8691f8211..4a5f206acd0 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -57,7 +57,7 @@ export interface Destination extends DestinationCore, AuditData { export type DestinationDetails = | AkamaiObjectStorageDetails - | CustomHTTPsDetails; + | CustomHTTPSDetails; export interface AkamaiObjectStorageDetails { access_key_id: string; @@ -74,7 +74,7 @@ export interface AkamaiObjectStorageDetailsExtended type ContentType = 'application/json' | 'application/json; charset=utf-8'; type DataCompressionType = 'gzip' | 'None'; -export interface CustomHTTPsDetails { +export interface CustomHTTPSDetails { authentication: Authentication; client_certificate_details?: ClientCertificateDetails; content_type: ContentType; @@ -84,13 +84,19 @@ export interface CustomHTTPsDetails { } interface ClientCertificateDetails { - client_ca_certificate: string; - client_certificate: string; - client_private_key: string; - tls_hostname: string; + client_ca_certificate?: string; + client_certificate?: string; + client_private_key?: string; + tls_hostname?: string; } -type AuthenticationType = 'basic' | 'none'; +export const authenticationType = { + Basic: 'basic', + None: 'none', +} as const; + +export type AuthenticationType = + (typeof authenticationType)[keyof typeof authenticationType]; interface Authentication { details?: AuthenticationDetails; @@ -133,7 +139,7 @@ export interface AkamaiObjectStorageDetailsPayload export type DestinationDetailsPayload = | AkamaiObjectStorageDetailsPayload - | CustomHTTPsDetails; + | CustomHTTPSDetails; export interface CreateDestinationPayload { details: DestinationDetailsPayload; diff --git a/packages/manager/.changeset/pr-13274-upcoming-features-1768306102161.md b/packages/manager/.changeset/pr-13274-upcoming-features-1768306102161.md new file mode 100644 index 00000000000..bf2a0574148 --- /dev/null +++ b/packages/manager/.changeset/pr-13274-upcoming-features-1768306102161.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Custom HTTPS destination type with proper fields to Create Destination forms ([#13274](https://github.com/linode/manager/pull/13274)) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index b0445bf0f18..2ce7fa7e1c8 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -120,6 +120,10 @@ interface AclpLogsFlag extends BetaFeatureFlag { * This property indicates whether to bypass account capabilities check or not */ bypassAccountCapabilities?: boolean; + /** + * This property indicates whether to show Custom HTTPS destination type + */ + customHttpsEnabled?: boolean; } interface LkeEnterpriseFlag extends BaseFeatureFlag { diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx index 5d98fe50105..d69ff14db29 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -12,9 +12,11 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { DestinationCreate } from './DestinationCreate'; import type { CreateDestinationPayload } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; describe('DestinationCreate', () => { const renderDestinationCreate = ( + flags: Partial, defaultValues?: Partial ) => { renderWithThemeAndHookFormContext({ @@ -25,88 +27,183 @@ describe('DestinationCreate', () => { ...defaultValues, }, }, + options: { + flags, + }, }); }; - it('should render disabled Destination Type input with proper selection', async () => { - renderDestinationCreate(); + describe('when customHttpsEnabled feature flag is set to false', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: false, + }, + }; - const destinationTypeAutocomplete = - screen.getByLabelText('Destination Type'); + it('should render disabled Destination Type input with proper selection', async () => { + renderDestinationCreate(flags); - expect(destinationTypeAutocomplete).toBeDisabled(); - expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); - }); + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); - it( - 'should render all inputs for Akamai Object Storage type and allow to fill out them', - { timeout: 10000 }, - async () => { - renderDestinationCreate({ label: '' }); + expect(destinationTypeAutocomplete).toBeDisabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); + }); - const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'test'); - const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); - const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); - await userEvent.type(logPathPrefixInput, 'Test'); + it( + 'should render all inputs for Akamai Object Storage type and allow to fill out them', + { timeout: 10000 }, + async () => { + renderDestinationCreate(flags, { label: '' }); + + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test'); + const hostInput = screen.getByLabelText('Host'); + await userEvent.type(hostInput, 'test'); + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'test'); + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIDInput, 'Test'); + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + await userEvent.type(logPathPrefixInput, 'Test'); - expect(destinationNameInput).toHaveValue('Test'); - expect(hostInput).toHaveValue('test'); - expect(bucketInput).toHaveValue('test'); - expect(accessKeyIDInput).toHaveValue('Test'); - expect(secretAccessKeyInput).toHaveValue('Test'); - expect(logPathPrefixInput).toHaveValue('Test'); - } - ); - - it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => { - const accountEuuid = 'XYZ-123'; - const [month, day, year] = new Date().toLocaleDateString().split('/'); - server.use( - http.get('*/account', () => { - return HttpResponse.json(accountFactory.build({ euuid: accountEuuid })); - }) + expect(destinationNameInput).toHaveValue('Test'); + expect(hostInput).toHaveValue('test'); + expect(bucketInput).toHaveValue('test'); + expect(accessKeyIDInput).toHaveValue('Test'); + expect(secretAccessKeyInput).toHaveValue('Test'); + expect(logPathPrefixInput).toHaveValue('Test'); + } ); - renderDestinationCreate(); + it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => { + const accountEuuid = 'XYZ-123'; + const [month, day, year] = new Date().toLocaleDateString().split('/'); + server.use( + http.get('*/account', () => { + return HttpResponse.json( + accountFactory.build({ euuid: accountEuuid }) + ); + }) + ); + + renderDestinationCreate(flags); + + let samplePath; + await waitFor(() => { + samplePath = screen.getByText( + `/audit_logs/com.akamai.audit/${accountEuuid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597-login.gz` + ); + expect(samplePath).toBeInTheDocument(); + }); + // Type the test value inside the input + const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + + await userEvent.type(logPathPrefixInput, 'test'); + // sample path should be created based on *log path* value + expect(samplePath!.textContent).toEqual( + '/test/akamai_log-000166-1756015362-319597-login.gz' + ); + + await userEvent.clear(logPathPrefixInput); + await userEvent.type(logPathPrefixInput, '/test'); + expect(samplePath!.textContent).toEqual( + '/test/akamai_log-000166-1756015362-319597-login.gz' + ); - let samplePath; - await waitFor(() => { - samplePath = screen.getByText( - `/audit_logs/com.akamai.audit/${accountEuuid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597-login.gz` + await userEvent.clear(logPathPrefixInput); + await userEvent.type(logPathPrefixInput, '/'); + expect(samplePath!.textContent).toEqual( + '/akamai_log-000166-1756015362-319597-login.gz' ); - expect(samplePath).toBeInTheDocument(); }); - // Type the test value inside the input - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + }); - await userEvent.type(logPathPrefixInput, 'test'); - // sample path should be created based on *log path* value - expect(samplePath!.textContent).toEqual( - '/test/akamai_log-000166-1756015362-319597-login.gz' - ); + describe('when customHttpsEnabled feature flag is set to true', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: true, + }, + }; - await userEvent.clear(logPathPrefixInput); - await userEvent.type(logPathPrefixInput, '/test'); - expect(samplePath!.textContent).toEqual( - '/test/akamai_log-000166-1756015362-319597-login.gz' - ); + it('should render enabled Destination Type input with Akamai Object Storage selected and allow to select Custom HTTPS', async () => { + renderDestinationCreate(flags); + + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); - await userEvent.clear(logPathPrefixInput); - await userEvent.type(logPathPrefixInput, '/'); - expect(samplePath!.textContent).toEqual( - '/akamai_log-000166-1756015362-319597-login.gz' + expect(destinationTypeAutocomplete).toBeEnabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); + }); + + it( + 'should render all inputs for Custom HTTPS type and allow to fill them out', + { timeout: 10000 }, + async () => { + renderDestinationCreate(flags, { label: '' }); + + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); + + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test'); + + // With None Authentication type selected, the Username and Password inputs should not be rendered + const notYetExistingUsernameInput = screen.queryByLabelText('Username'); + expect(notYetExistingUsernameInput).not.toBeInTheDocument(); + const notYetExistingPasswordInput = screen.queryByLabelText('Password'); + expect(notYetExistingPasswordInput).not.toBeInTheDocument(); + + // Open Authentication select and choose Basic option + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + expect(authenticationAutocomplete).toHaveValue('None'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + expect(authenticationAutocomplete).toHaveValue('Basic'); + + // With Authentication type set to Basic, the Username and Password inputs should be rendered + const usernameInput = screen.getByLabelText('Username'); + await userEvent.type(usernameInput, 'Username test'); + expect(usernameInput.getAttribute('value')).toEqual('Username test'); + + const passwordInput = screen.getByLabelText('Password'); + await userEvent.type(passwordInput, 'Password test'); + expect(passwordInput.getAttribute('value')).toEqual('Password test'); + + // Endpoint URL + const endpointUrlInput = screen.getByLabelText('Endpoint URL'); + await userEvent.type(endpointUrlInput, 'Endpoint URL test'); + expect(endpointUrlInput.getAttribute('value')).toEqual( + 'Endpoint URL test' + ); + } ); }); describe('given Test Connection and Create Destination buttons', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: false, + }, + }; const testConnectionButtonText = 'Test Connection'; const createDestinationButtonText = 'Create Destination'; @@ -144,7 +241,7 @@ describe('DestinationCreate', () => { }) ); - renderDestinationCreate(); + renderDestinationCreate(flags); const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, @@ -181,7 +278,7 @@ describe('DestinationCreate', () => { }) ); - renderDestinationCreate(); + renderDestinationCreate(flags); const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index b03e7c93395..800dab36f16 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -1,4 +1,4 @@ -import { destinationType } from '@linode/api-v4'; +import { authenticationType, destinationType } from '@linode/api-v4'; import { Autocomplete, Paper, TextField } from '@linode/ui'; import { capitalize, scrollErrorIntoViewV2 } from '@linode/utilities'; import Grid from '@mui/material/Grid'; @@ -8,8 +8,12 @@ import type { SubmitHandler } from 'react-hook-form'; import { useFormContext } from 'react-hook-form'; import { Controller, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { + getDestinationTypeOption, + useIsACLPLogsEnabled, +} from 'src/features/Delivery/deliveryUtils'; import { DestinationAkamaiObjectStorageDetailsForm } from 'src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm'; +import { DestinationCustomHttpsDetailsForm } from 'src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm'; import { FormSubmitBar } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar'; import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; import { useVerifyDestination } from 'src/features/Delivery/Shared/useVerifyDestination'; @@ -28,6 +32,7 @@ interface DestinationFormProps { export const DestinationForm = (props: DestinationFormProps) => { const { mode, isSubmitting, onSubmit } = props; + const { isACLPLogsCustomHttpsEnabled } = useIsACLPLogsEnabled(); const { verifyDestination, isPending: isVerifyingDestination, @@ -36,7 +41,8 @@ export const DestinationForm = (props: DestinationFormProps) => { } = useVerifyDestination(); const formRef = React.useRef(null); - const { control, handleSubmit } = useFormContext(); + const { control, handleSubmit, setValue } = + useFormContext(); const destination = useWatch({ control, }) as DestinationFormType; @@ -56,10 +62,16 @@ export const DestinationForm = (props: DestinationFormProps) => { render={({ field }) => ( { + if (value === destinationType.CustomHttps) { + setValue( + 'details.authentication.type', + authenticationType.None + ); + } field.onChange(value); }} options={destinationTypeOptions} @@ -98,6 +110,13 @@ export const DestinationForm = (props: DestinationFormProps) => { mode={mode} /> )} + {isACLPLogsCustomHttpsEnabled && + destination.type === destinationType.CustomHttps && ( + + )} diff --git a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx new file mode 100644 index 00000000000..1b47c5443f1 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx @@ -0,0 +1,125 @@ +import { Autocomplete, TextField } from '@linode/ui'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { HideShowText } from 'src/components/PasswordInput/HideShowText'; +import { getAuthenticationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { authenticationTypeOptions } from 'src/features/Delivery/Shared/types'; + +import type { FormMode, FormType } from 'src/features/Delivery/Shared/types'; + +interface DestinationCustomHttpsDetailsFormProps { + controlPaths?: { + authenticationType: string; + basicAuthenticationPassword: string; + basicAuthenticationUser: string; + clientCertificateDetails: string; + contentType: string; + customHeaders: string; + dataCompression: string; + endpointUrl: string; + }; + entity: FormType; + mode: FormMode; +} + +const defaultPaths = { + authenticationType: 'details.authentication.type', + basicAuthenticationPassword: + 'details.authentication.details.basic_authentication_password', + basicAuthenticationUser: + 'details.authentication.details.basic_authentication_user', + clientCertificateDetails: 'details.client_certificate_details', + contentType: 'details.content_type', + customHeaders: 'details.custom_headers', + dataCompression: 'details.data_compression', + endpointUrl: 'details.endpoint_url', +}; + +export const DestinationCustomHttpsDetailsForm = ( + props: DestinationCustomHttpsDetailsFormProps +) => { + const { controlPaths = defaultPaths } = props; + + const { control } = useFormContext(); + + const selectedAuthenticationType = useWatch({ + control, + name: controlPaths.authenticationType, + }); + + return ( + <> + ( + { + field.onChange(value); + }} + options={authenticationTypeOptions} + value={getAuthenticationTypeOption(field.value)} + /> + )} + /> + {selectedAuthenticationType === 'basic' && ( + <> + ( + { + field.onChange(value); + }} + placeholder="Username" + value={field.value} + /> + )} + /> + ( + field.onChange(value)} + placeholder="Password" + value={field.value} + /> + )} + /> + + )} + ( + { + field.onChange(value); + }} + placeholder="Endpoint URL" + value={field.value} + /> + )} + /> + + ); +}; diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index 205e44573bd..ff4cf6bae6a 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -1,9 +1,14 @@ -import { destinationType, streamStatus, streamType } from '@linode/api-v4'; +import { + authenticationType, + destinationType, + streamStatus, + streamType, +} from '@linode/api-v4'; import type { AkamaiObjectStorageDetailsExtended, CreateDestinationPayload, - CustomHTTPsDetails, + CustomHTTPSDetails, } from '@linode/api-v4'; export type FormMode = 'create' | 'edit'; @@ -50,9 +55,20 @@ export const streamStatusOptions: AutocompleteOption[] = [ }, ]; +export const authenticationTypeOptions: AutocompleteOption[] = [ + { + value: authenticationType.Basic, + label: 'Basic', + }, + { + value: authenticationType.None, + label: 'None', + }, +]; + export type DestinationDetailsForm = | AkamaiObjectStorageDetailsExtended - | CustomHTTPsDetails; + | CustomHTTPSDetails; export interface DestinationForm extends Omit { diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 656b8b70792..16e17569225 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -11,9 +11,23 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { StreamFormDelivery } from './StreamFormDelivery'; +import type { Destination, DestinationType } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + const loadingTestId = 'circle-progress'; -const mockDestinations = destinationFactory.buildList(5); +const mockDestinations = destinationFactory + .buildList(5) + .map((destination: Destination) => { + if (destination.id === 3) { + return { + ...destination, + type: destinationType.CustomHttps, + }; + } else { + return destination; + } + }); describe('StreamFormDelivery', () => { const setDisableTestConnection = () => {}; @@ -26,70 +40,10 @@ describe('StreamFormDelivery', () => { ); }); - it('should render disabled Destination Type input with proper selection', async () => { - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - destination: { - type: destinationType.AkamaiObjectStorage, - }, - }, - }, - }); - - const loadingElement = screen.queryByTestId(loadingTestId); - expect(loadingElement).toBeInTheDocument(); - await waitForElementToBeRemoved(loadingElement); - - const destinationTypeAutocomplete = - screen.getByLabelText('Destination Type'); - - expect(destinationTypeAutocomplete).toBeDisabled(); - expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); - }); - - it('should render Destination Name input and allow to select an existing option', async () => { - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - destination: { - label: '', - type: destinationType.AkamaiObjectStorage, - }, - }, - }, - }); - - const loadingElement = screen.queryByTestId(loadingTestId); - expect(loadingElement).toBeInTheDocument(); - await waitForElementToBeRemoved(loadingElement); - - const destinationNameAutocomplete = - screen.getByLabelText('Destination Name'); - - // Open the dropdown - await userEvent.click(destinationNameAutocomplete); - - // Select the "Destination 1" option - const firstDestination = await screen.findByText('Destination 1'); - await userEvent.click(firstDestination); - - expect(destinationNameAutocomplete).toHaveValue('Destination 1'); - }); - - const renderComponentAndAddNewDestinationName = async () => { + const renderComponentAndAddNewDestinationName = async ( + destinationTypeToSet: DestinationType, + flags: Partial + ) => { renderWithThemeAndHookFormContext({ component: ( { }, }, }, + options: { flags }, }); const loadingElement = screen.queryByTestId(loadingTestId); expect(loadingElement).toBeInTheDocument(); await waitForElementToBeRemoved(loadingElement); + if ( + flags.aclpLogs?.customHttpsEnabled && + destinationTypeToSet === destinationType.CustomHttps + ) { + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + + expect(destinationTypeAutocomplete).toBeEnabled(); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); + } + const destinationNameAutocomplete = screen.getByLabelText('Destination Name'); @@ -131,65 +100,343 @@ describe('StreamFormDelivery', () => { await userEvent.click(createNewTestDestination); }; - it('should render Destination Name input and allow to add a new option', async () => { - await renderComponentAndAddNewDestinationName(); + describe('when customHttpsEnabled feature flag is set to false', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: false, + }, + }; + + it('should render disabled Destination Type input with Akamai Object Storage selected', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + type: destinationType.AkamaiObjectStorage, + }, + }, + }, + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); - const destinationNameAutocomplete = - screen.getByLabelText('Destination Name'); + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); - // Move focus away from the dropdown - await userEvent.tab(); + expect(destinationTypeAutocomplete).toBeDisabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); + }); - expect(destinationNameAutocomplete).toHaveValue('New test destination'); + describe('and Destination Type is set to Akamai Object Storage', () => { + it('should render Destination Name input and allow to select an existing option', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + label: '', + type: destinationType.AkamaiObjectStorage, + }, + }, + }, + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); + + // Open the dropdown + await userEvent.click(destinationNameAutocomplete); + + // Select the "Destination 1" option + const firstDestination = await screen.findByText('Destination 1'); + await userEvent.click(firstDestination); + + expect(destinationNameAutocomplete).toHaveValue('Destination 1'); + }); + + it('should render Destination Name input and allow to add a new option', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); + + // Move focus away from the dropdown + await userEvent.tab(); + + expect(destinationNameAutocomplete).toHaveValue('New test destination'); + }); + + describe('and new Destination Name is added', () => { + it('should render Host input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const hostInput = screen.getByLabelText('Host'); + await userEvent.type(hostInput, 'Test'); + + expect(hostInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Bucket input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'test'); + + expect(bucketInput.getAttribute('value')).toEqual('test'); + }); + + it('should render Access Key ID input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIDInput, 'Test'); + + expect(accessKeyIDInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Secret Access Key input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const secretAccessKeyInput = + screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + + expect(secretAccessKeyInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Log Path Prefix input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + await userEvent.type(logPathPrefixInput, 'Test'); + + expect(logPathPrefixInput.getAttribute('value')).toEqual('Test'); + }); + }); + }); }); - it('should render Host input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + describe('when customHttpsEnabled feature flag is set to true', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: true, + }, + }; + + it('should render enabled Destination Type input with Akamai Object Storage selected and allow to select Custom HTTPS', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + type: destinationType.AkamaiObjectStorage, + }, + }, + }, + options: { + flags, + }, + }); - // Type the test value inside the input - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'Test'); + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); - expect(hostInput.getAttribute('value')).toEqual('Test'); - }); + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); - it('should render Bucket input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + expect(destinationTypeAutocomplete).toBeEnabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); + }); - // Type the test value inside the input - const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); + describe('and Destination Type is set to Custom HTTPS', () => { + it('should render Destination Name input and allow to select an existing option', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + label: '', + type: destinationType.CustomHttps, + }, + }, + }, + options: { + flags, + }, + }); - expect(bucketInput.getAttribute('value')).toEqual('test'); - }); + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); - it('should render Access Key ID input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); - // Type the test value inside the input - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); + // Open the dropdown + await userEvent.click(destinationNameAutocomplete); - expect(accessKeyIDInput.getAttribute('value')).toEqual('Test'); - }); + // Select the "Destination 3" option + const customHttpsDestination = await screen.findByText('Destination 3'); + await userEvent.click(customHttpsDestination); - it('should render Secret Access Key input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + expect(destinationNameAutocomplete).toHaveValue('Destination 3'); + }); - // Type the test value inside the input - const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); + it('should render Destination Name input and allow to add a new option', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); - expect(secretAccessKeyInput.getAttribute('value')).toEqual('Test'); - }); + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); + + // Move focus away from the dropdown + await userEvent.tab(); + + expect(destinationNameAutocomplete).toHaveValue('New test destination'); + }); + + describe('and new Destination Name is added', () => { + it('should render Authentication autocomplete with None selected and allow to select Basic', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + + expect(authenticationAutocomplete).toHaveValue('None'); + + // Open the dropdown + await userEvent.click(authenticationAutocomplete); - it('should render Log Path Prefix input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + // Select the "Basic" option + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); - // Type the test value inside the input - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); - await userEvent.type(logPathPrefixInput, 'Test'); + expect(authenticationAutocomplete).toHaveValue('Basic'); + }); - expect(logPathPrefixInput.getAttribute('value')).toEqual('Test'); + describe('and Authentication is set to Basic', () => { + it('should render Username input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + // Select the "Basic" Authentication option + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + expect(authenticationAutocomplete).toHaveValue('Basic'); + + // Type the test value inside the input + const usernameInput = screen.getByLabelText('Username'); + await userEvent.type(usernameInput, 'Test'); + + expect(usernameInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Password input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + // Select the "Basic" Authentication option + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + expect(authenticationAutocomplete).toHaveValue('Basic'); + + // Type the test value inside the input + const passwordInput = screen.getByLabelText('Password'); + await userEvent.type(passwordInput, 'Test'); + + expect(passwordInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render Endpoint URL input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + // Type the test value inside the input + const endpointUrlInput = screen.getByLabelText('Endpoint URL'); + await userEvent.type(endpointUrlInput, 'Test'); + + expect(endpointUrlInput.getAttribute('value')).toEqual('Test'); + }); + }); + }); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 8a00438d52d..309226ba0ed 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -15,8 +15,12 @@ import { useTheme } from '@mui/material/styles'; import React, { useEffect, useState } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { + getDestinationTypeOption, + useIsACLPLogsEnabled, +} from 'src/features/Delivery/deliveryUtils'; import { DestinationAkamaiObjectStorageDetailsForm } from 'src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm'; +import { DestinationCustomHttpsDetailsForm } from 'src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm'; import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; import { DestinationAkamaiObjectStorageDetailsSummary } from 'src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary'; @@ -35,7 +39,7 @@ interface DestinationName { type?: DestinationType; } -const controlPaths = { +const akamaiObjectStorageDetailsControlPaths = { accessKeyId: 'destination.details.access_key_id', accessKeySecret: 'destination.details.access_key_secret', bucketName: 'destination.details.bucket_name', @@ -43,6 +47,19 @@ const controlPaths = { path: 'destination.details.path', } as const; +const customHttpsDetailsControlPaths = { + authenticationType: 'destination.details.authentication.type', + basicAuthenticationPassword: + 'destination.details.authentication.details.basic_authentication_password', + basicAuthenticationUser: + 'destination.details.authentication.details.basic_authentication_user', + clientCertificateDetails: 'destination.details.client_certificate_details', + contentType: 'destination.details.content_type', + customHeaders: 'destination.details.custom_headers', + dataCompression: 'destination.details.data_compression', + endpointUrl: 'destination.details.endpoint_url', +} as const; + interface StreamFormDeliveryProps { mode: FormMode; setDisableTestConnection: (disable: boolean) => void; @@ -51,6 +68,7 @@ interface StreamFormDeliveryProps { export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const { mode, setDisableTestConnection } = props; + const { isACLPLogsCustomHttpsEnabled } = useIsACLPLogsEnabled(); const theme = useTheme(); const { control, setValue, clearErrors } = useFormContext(); @@ -91,8 +109,8 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { destinations?.find((destination) => destination.id === id); const restDestinationForm = () => { - Object.values(controlPaths).forEach((controlPath) => - setValue(controlPath, '') + Object.values(akamaiObjectStorageDetailsControlPaths).forEach( + (controlPath) => setValue(controlPath, '') ); }; @@ -104,11 +122,17 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { render={({ field, fieldState }) => ( { + if (value === destinationType.CustomHttps) { + setValue( + customHttpsDetailsControlPaths.authenticationType, + 'none' + ); + } field.onChange(value); }} options={destinationTypeOptions} @@ -226,7 +250,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { <> {creatingNewDestination && !selectedDestinations?.length && ( @@ -239,6 +263,16 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { )} )} + {isACLPLogsCustomHttpsEnabled && + selectedDestinationType === destinationType.CustomHttps && + creatingNewDestination && + !selectedDestinations?.length && ( + + )} ); diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 4710be9407e..53707dd0e97 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -12,6 +12,7 @@ import { omitProps } from '@linode/ui'; import { isFeatureEnabledV2 } from '@linode/utilities'; import { + authenticationTypeOptions, destinationTypeOptions, streamTypeOptions, } from 'src/features/Delivery/Shared/types'; @@ -30,6 +31,7 @@ import type { */ export const useIsACLPLogsEnabled = (): { isACLPLogsBeta: boolean; + isACLPLogsCustomHttpsEnabled: boolean; isACLPLogsEnabled: boolean; } => { const { data: account } = useAccount(); @@ -45,6 +47,7 @@ export const useIsACLPLogsEnabled = (): { return { isACLPLogsBeta: !!flags.aclpLogs?.beta, + isACLPLogsCustomHttpsEnabled: !!flags.aclpLogs?.customHttpsEnabled, isACLPLogsEnabled, }; }; @@ -59,6 +62,13 @@ export const getStreamTypeOption = ( ): AutocompleteOption | undefined => streamTypeOptions.find(({ value }) => value === streamTypeValue); +export const getAuthenticationTypeOption = ( + authenticationTypeValue: string +): AutocompleteOption | undefined => + authenticationTypeOptions.find( + ({ value }) => value === authenticationTypeValue + ); + export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; export const getStreamPayloadDetails = ( diff --git a/packages/validation/.changeset/pr-13274-changed-1768306454830.md b/packages/validation/.changeset/pr-13274-changed-1768306454830.md new file mode 100644 index 00000000000..11ce13394ab --- /dev/null +++ b/packages/validation/.changeset/pr-13274-changed-1768306454830.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Adjust Custom HTTPS Destination validation ([#13274](https://github.com/linode/manager/pull/13274)) diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index 28a183b16f5..867a2ce2b5d 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -1,4 +1,13 @@ -import { array, boolean, lazy, mixed, number, object, string } from 'yup'; +import { + array, + boolean, + lazy, + mixed, + number, + object, + string, + ValidationError, +} from 'yup'; import type { InferType, MixedSchema, Schema } from 'yup'; @@ -10,14 +19,16 @@ const maxLengthMessage = 'Length must be 255 characters or less.'; const authenticationDetailsSchema = object({ basic_authentication_user: string() .max(maxLength, maxLengthMessage) - .required(), + .required('Username is required for Basic Authentication.'), basic_authentication_password: string() .max(maxLength, maxLengthMessage) - .required(), + .required('Password is required for Basic Authentication.'), }); const authenticationSchema = object({ - type: string().oneOf(['basic', 'none']).required(), + type: string() + .oneOf(['basic', 'none']) + .required('Authentication is required.'), details: mixed() .defined() .when('type', { @@ -28,33 +39,106 @@ const authenticationSchema = object({ .nullable() .test( 'null-or-undefined', - 'For type `none` details should be `null` or `undefined`.', + 'For none authentication details should be `null` or `undefined`.', (value) => !value, ), }) as Schema | undefined>, }); +const hasValue = (value: unknown) => + typeof value === 'string' && value.trim().length > 0; + const clientCertificateDetailsSchema = object({ - tls_hostname: string().max(maxLength, maxLengthMessage).required(), - client_ca_certificate: string().required(), - client_certificate: string().required(), - client_private_key: string().required(), -}); + tls_hostname: string().max(maxLength, maxLengthMessage), + client_ca_certificate: string(), + client_certificate: string(), + client_private_key: string(), +}).test( + 'all-or-nothing-cert-details', + 'If any certificate detail is provided, all are required.', + (value, context) => { + if (!value) { + return true; + } + + const { + client_ca_certificate, + client_certificate, + client_private_key, + tls_hostname, + } = value; + + const fields = [ + tls_hostname, + client_ca_certificate, + client_certificate, + client_private_key, + ]; + const hasAnyValue = fields.some(hasValue); + const hasAllValues = fields.every(hasValue); + + if (!hasAnyValue || hasAllValues) { + return true; + } + + const errors: ValidationError[] = []; + if (!hasValue(tls_hostname)) { + errors.push( + context.createError({ + path: 'tls_hostname', + message: + 'TLS Hostname is required when other certificate details are provided.', + }), + ); + } + if (!hasValue(client_ca_certificate)) { + errors.push( + context.createError({ + path: 'client_ca_certificate', + message: + 'CA Certificate is required when other certificate details are provided.', + }), + ); + } + if (!hasValue(client_certificate)) { + errors.push( + context.createError({ + path: 'client_certificate', + message: + 'Client Certificate is required when other certificate details are provided.', + }), + ); + } + if (!hasValue(client_private_key)) { + errors.push( + context.createError({ + path: 'client_private_key', + message: + 'Client Key is required when other certificate details are provided.', + }), + ); + } + + return new ValidationError(errors); + }, +); const customHeaderSchema = object({ name: string().max(maxLength, maxLengthMessage).required(), value: string().max(maxLength, maxLengthMessage).required(), }); -const customHTTPsDetailsSchema = object({ +const customHTTPSDetailsSchema = object({ authentication: authenticationSchema.required(), client_certificate_details: clientCertificateDetailsSchema.optional(), content_type: string() .oneOf(['application/json', 'application/json; charset=utf-8']) - .required(), + .required('Content Type is required.'), custom_headers: array().of(customHeaderSchema).min(1).optional(), data_compression: string().oneOf(['gzip', 'None']).required(), - endpoint_url: string().max(maxLength, maxLengthMessage).required(), + endpoint_url: string() + .max(maxLength, maxLengthMessage) + .required('Endpoint URL is required.'), }); const hostRgx = @@ -123,14 +207,14 @@ const destinationSchemaBase = object().shape({ type: string().oneOf(['akamai_object_storage', 'custom_https']).required(), details: mixed< | InferType - | InferType + | InferType >() .defined() .required() .when('type', { is: 'akamai_object_storage', then: () => akamaiObjectStorageDetailsBaseSchema, - otherwise: () => customHTTPsDetailsSchema, + otherwise: () => customHTTPSDetailsSchema, }), }); @@ -139,14 +223,14 @@ export const destinationFormSchema = destinationSchemaBase; export const createDestinationSchema = destinationSchemaBase.shape({ details: mixed< | InferType - | InferType + | InferType >() .defined() .required() .when('type', { is: 'akamai_object_storage', then: () => akamaiObjectStorageDetailsPayloadSchema, - otherwise: () => customHTTPsDetailsSchema, + otherwise: () => customHTTPSDetailsSchema, }), }); @@ -160,7 +244,7 @@ export const updateDestinationSchema = createDestinationSchema ); } if ('client_certificate_details' in value) { - return customHTTPsDetailsSchema.noUnknown( + return customHTTPSDetailsSchema.noUnknown( 'Object contains unknown fields for Custom HTTPS Details.', ); }