diff --git a/packages/api-v4/.changeset/pr-13331-changed-1769526663262.md b/packages/api-v4/.changeset/pr-13331-changed-1769526663262.md new file mode 100644 index 00000000000..6c90784bdf1 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13331-changed-1769526663262.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Adjust Custom HTTPS Destination types: content type, data compression, custom headers ([#13331](https://github.com/linode/manager/pull/13331)) diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index 717f0d830e9..5dccc484b1a 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -72,13 +72,25 @@ export interface AkamaiObjectStorageDetailsExtended access_key_secret: string; } -type ContentType = 'application/json' | 'application/json; charset=utf-8'; -type DataCompressionType = 'gzip' | 'None'; +export const contentType = { + Json: 'application/json', + JsonUtf8: 'application/json; charset=utf-8', +} as const; + +export type ContentType = (typeof contentType)[keyof typeof contentType] | null; + +export const dataCompressionType = { + Gzip: 'gzip', + None: 'None', +} as const; + +export type DataCompressionType = + (typeof dataCompressionType)[keyof typeof dataCompressionType]; export interface CustomHTTPSDetails { authentication: Authentication; client_certificate_details?: ClientCertificateDetails; - content_type: ContentType; + content_type?: ContentType; custom_headers?: CustomHeader[]; data_compression: DataCompressionType; endpoint_url: string; @@ -109,7 +121,7 @@ interface AuthenticationDetails { basic_authentication_user: string; } -interface CustomHeader { +export interface CustomHeader { name: string; value: string; } diff --git a/packages/manager/.changeset/pr-13331-upcoming-features-1769526774343.md b/packages/manager/.changeset/pr-13331-upcoming-features-1769526774343.md new file mode 100644 index 00000000000..e54217e8922 --- /dev/null +++ b/packages/manager/.changeset/pr-13331-upcoming-features-1769526774343.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Additional Options section to the Custom HTTPS destination type ([#13331](https://github.com/linode/manager/pull/13331)) diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts index c1848efea09..e09ab3637a0 100644 --- a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts @@ -7,7 +7,7 @@ import { import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import type { Destination } from '@linode/api-v4'; @@ -95,7 +95,7 @@ function editDestinationViaActionMenu( const mockDestinations: Destination[] = new Array(3) .fill(null) .map((_item: null, index: number): Destination => { - return destinationFactory.build({ + return akamaiObjectStorageDestinationFactory.build({ label: `Destination ${index}`, }); }); diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts index 290974a6a2f..3c051308bd5 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -1,7 +1,10 @@ import { destinationType, streamType } from '@linode/api-v4'; import { randomLabel, randomString } from 'support/util/random'; -import { destinationFactory, streamFactory } from 'src/factories'; +import { + akamaiObjectStorageDestinationFactory, + streamFactory, +} from 'src/factories'; import type { CreateDestinationPayload, @@ -22,11 +25,12 @@ export const mockDestinationPayload: CreateDestinationPayload = { }, }; -export const mockDestination: Destination = destinationFactory.build({ - id: 1290, - ...mockDestinationPayload, - version: '1.0', -}); +export const mockDestination: Destination = + akamaiObjectStorageDestinationFactory.build({ + id: 1290, + ...mockDestinationPayload, + version: '1.0', + }); export const mockDestinationPayloadWithId = { id: mockDestination.id, diff --git a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts index 01621e540b8..e129c119e41 100644 --- a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts +++ b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts @@ -43,7 +43,6 @@ export const logsDestinationForm = { cy.findByLabelText('Bucket') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Bucket') .clear(); cy.focused().type(bucketName); }, @@ -57,7 +56,6 @@ export const logsDestinationForm = { cy.findByLabelText('Access Key ID') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Access Key ID') .clear(); cy.focused().type(accessKeyId); }, @@ -71,7 +69,6 @@ export const logsDestinationForm = { cy.findByLabelText('Secret Access Key') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Secret Access Key') .clear(); cy.focused().type(secretAccessKey); }, diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index c3c19a3f472..d44c0be2971 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -3,27 +3,52 @@ import { Factory } from '@linode/utilities'; import type { Destination } from '@linode/api-v4'; -export const destinationFactory = Factory.Sync.makeFactory({ - details: { - access_key_id: 'Access Id', - bucket_name: 'destinations-bucket-name', - host: 'destinations-bucket-name.host.com', - path: 'file', - }, - id: Factory.each((id) => id), - label: Factory.each((id) => `Destination ${id}`), - type: destinationType.AkamaiObjectStorage, - version: '1.0', - updated: '2025-07-30', - updated_by: 'username', - created: '2025-07-30', - created_by: 'username', -}); +let destinationIdCounter = 0; +const nextDestinationId = () => ++destinationIdCounter; + +export const akamaiObjectStorageDestinationFactory = + Factory.Sync.makeFactory({ + details: { + access_key_id: 'Access Id', + bucket_name: 'destinations-bucket-name', + host: 'destinations-bucket-name.host.com', + path: 'file', + }, + id: Factory.each(() => nextDestinationId()), + label: Factory.each((id) => `Akamai Object Storage Destination ${id}`), + type: destinationType.AkamaiObjectStorage, + version: '1.0', + updated: '2025-07-30', + updated_by: 'username', + created: '2025-07-30', + created_by: 'username', + }); + +export const customHttpsDestinationFactory = + Factory.Sync.makeFactory({ + details: { + authentication: { + type: 'none', + }, + data_compression: 'None', + endpoint_url: 'https://example.com/endpoint', + content_type: 'application/json', + custom_headers: [{ name: 'X-Test', value: '1' }], + }, + id: Factory.each(() => nextDestinationId()), + label: Factory.each((id) => `Custom HTTPS Destination ${id}`), + type: destinationType.CustomHttps, + version: '1.0', + updated: '2025-07-30', + updated_by: 'username', + created: '2025-07-30', + created_by: 'username', + }); export const streamFactory = Factory.Sync.makeFactory({ created_by: 'username', destinations: Factory.each(() => [ - { ...destinationFactory.build(), id: 123 }, + { ...akamaiObjectStorageDestinationFactory.build(), id: 123 }, ]), details: null, updated: '2025-07-30', diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx index 9c1c6b0662b..710487ff3e0 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx @@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { DestinationActionMenu } from 'src/features/Delivery/Destinations/DestinationActionMenu'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -12,7 +12,7 @@ describe('DestinationActionMenu', () => { it('should include proper Stream actions', async () => { renderWithTheme( 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 d69ff14db29..87b81c7e367 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -3,7 +3,7 @@ import { profileFactory } from '@linode/utilities'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { describe, expect } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { accountFactory } from 'src/factories'; import { http, HttpResponse, server } from 'src/mocks/testServer'; @@ -14,6 +14,10 @@ import { DestinationCreate } from './DestinationCreate'; import type { CreateDestinationPayload } from '@linode/api-v4'; import type { Flags } from 'src/featureFlags'; +const testConnectionButtonText = 'Test Connection'; +const createDestinationButtonText = 'Create Destination'; +const addCustomHeaderButtonText = 'Add Custom Header'; + describe('DestinationCreate', () => { const renderDestinationCreate = ( flags: Partial, @@ -27,9 +31,7 @@ describe('DestinationCreate', () => { ...defaultValues, }, }, - options: { - flags, - }, + options: { flags }, }); }; @@ -42,7 +44,7 @@ describe('DestinationCreate', () => { }, }; - it('should render disabled Destination Type input with proper selection', async () => { + it('should render disabled Destination Type input with Akamai Object Storage selected', () => { renderDestinationCreate(flags); const destinationTypeAutocomplete = @@ -52,74 +54,194 @@ describe('DestinationCreate', () => { expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); }); - it( - 'should render all inputs for Akamai Object Storage type and allow to fill out them', - { timeout: 10000 }, - async () => { - renderDestinationCreate(flags, { label: '' }); + describe('and Destination Type is set to Akamai Object Storage', () => { + it('should render Destination Name input and allow to type text', async () => { + renderDestinationCreate(flags); const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); + await userEvent.type(destinationNameInput, 'Test Destination'); + + expect(destinationNameInput).toHaveValue('Test Destination'); + }); + + it('should render Host input and allow to type text', async () => { + renderDestinationCreate(flags); + const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'test'); + await userEvent.type(hostInput, 'test-host.com'); + + expect(hostInput).toHaveValue('test-host.com'); + }); + + it('should render Bucket input and allow to type text', async () => { + renderDestinationCreate(flags); + const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); + await userEvent.type(bucketInput, 'test-bucket'); + + expect(bucketInput).toHaveValue('test-bucket'); + }); + + it('should render Access Key ID input and allow to type text', async () => { + renderDestinationCreate(flags); + + const accessKeyIdInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIdInput, 'test-access-key'); + + expect(accessKeyIdInput).toHaveValue('test-access-key'); + }); + + it('should render Secret Access Key input and allow to type text', async () => { + renderDestinationCreate(flags); + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); + await userEvent.type(secretAccessKeyInput, 'test-secret-key'); + + expect(secretAccessKeyInput).toHaveValue('test-secret-key'); + }); + + it('should render Log Path Prefix input and allow to type text', async () => { + renderDestinationCreate(flags); + 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 }) + await userEvent.type(logPathPrefixInput, 'test-path'); + + expect(logPathPrefixInput).toHaveValue('test-path'); + }); + + 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'); - renderDestinationCreate(flags); + 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' - ); - - await userEvent.clear(logPathPrefixInput); - await userEvent.type(logPathPrefixInput, '/test'); - expect(samplePath!.textContent).toEqual( - '/test/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' - ); + + describe('given Test Connection and Create Destination buttons', () => { + const fillOutAkamaiObjectStorageForm = async () => { + 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'); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Create Destination button and perform proper call when it's clicked", async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json({}); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutAkamaiObjectStorageForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(createDestinationButton).toBeEnabled(); + }); + + await userEvent.click(createDestinationButton); + expect(createDestinationSpy).toHaveBeenCalled(); + }); + }); + + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Create Destination button', async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutAkamaiObjectStorageForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + expect(createDestinationButton).toBeDisabled(); + }); + }); + }); }); }); @@ -140,158 +262,322 @@ describe('DestinationCreate', () => { 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: '' }); + describe('and Destination Type is set to Custom HTTPS', () => { + const selectCustomHttpsDestinationType = async () => { + renderDestinationCreate(flags); 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'); + }; + + it('should render Destination Name input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); + await userEvent.type(destinationNameInput, 'Test Destination'); - // 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(); + expect(destinationNameInput).toHaveValue('Test Destination'); + }); + + it('should render Authentication autocomplete with None selected and allow to select Basic', async () => { + await selectCustomHttpsDestinationType(); - // 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'); + describe('and Authentication is set to Basic', () => { + it('should render Username input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); - const passwordInput = screen.getByLabelText('Password'); - await userEvent.type(passwordInput, 'Password test'); - expect(passwordInput.getAttribute('value')).toEqual('Password test'); + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); - // Endpoint URL - const endpointUrlInput = screen.getByLabelText('Endpoint URL'); - await userEvent.type(endpointUrlInput, 'Endpoint URL test'); - expect(endpointUrlInput.getAttribute('value')).toEqual( - 'Endpoint URL test' - ); - } - ); - }); + const usernameInput = screen.getByLabelText('Username'); + await userEvent.type(usernameInput, 'test-user'); - 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'; - - const fillOutForm = async () => { - 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(usernameInput).toHaveValue('test-user'); + }); - describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { - const createDestinationSpy = vi.fn(); - const verifyDestinationSpy = vi.fn(); + it('should render Password input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); - it("should enable Create Destination button and perform proper call when it's clicked", async () => { - server.use( - http.post('*/monitor/streams/destinations/verify', () => { - verifyDestinationSpy(); - return HttpResponse.json({}); - }), - http.post('*/monitor/streams/destinations', () => { - createDestinationSpy(); - return HttpResponse.json({}); - }), - http.get('*/profile', () => { - return HttpResponse.json(profileFactory.build()); - }) - ); + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); - renderDestinationCreate(flags); + const passwordInput = screen.getByLabelText('Password'); + await userEvent.type(passwordInput, 'test-password'); - const testConnectionButton = screen.getByRole('button', { - name: testConnectionButtonText, + expect(passwordInput).toHaveValue('test-password'); }); - const createDestinationButton = screen.getByRole('button', { - name: createDestinationButtonText, + }); + + it('should render Endpoint URL input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const endpointUrlInput = screen.getByLabelText('Endpoint URL'); + await userEvent.type(endpointUrlInput, 'https://test-endpoint.com'); + + expect(endpointUrlInput).toHaveValue('https://test-endpoint.com'); + }); + + describe('Client Certificate fields', () => { + it('should render TLS Hostname input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const tlsHostnameInput = screen.getByLabelText('TLS Hostname'); + await userEvent.type(tlsHostnameInput, 'test-tls-hostname'); + + expect(tlsHostnameInput).toHaveValue('test-tls-hostname'); }); - await fillOutForm(); - expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); - expect(verifyDestinationSpy).toHaveBeenCalled(); + it('should render CA Certificate input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); - await waitFor(() => { - expect(createDestinationButton).toBeEnabled(); + const caCertificateInput = screen.getByLabelText('CA Certificate'); + await userEvent.type(caCertificateInput, 'test-ca-certificate'); + + expect(caCertificateInput).toHaveValue('test-ca-certificate'); + }); + + it('should render Client Certificate input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const clientCertificateInput = + screen.getByLabelText('Client Certificate'); + await userEvent.type( + clientCertificateInput, + 'test-client-certificate' + ); + + expect(clientCertificateInput).toHaveValue('test-client-certificate'); }); - await userEvent.click(createDestinationButton); - expect(createDestinationSpy).toHaveBeenCalled(); + it('should render Client Key input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const clientKeyInput = screen.getByLabelText('Client Key'); + await userEvent.type(clientKeyInput, 'test-client-key'); + + expect(clientKeyInput).toHaveValue('test-client-key'); + }); }); - }); - describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { - const verifyDestinationSpy = vi.fn(); + describe('HTTPS Headers fields', () => { + it('should render Content Type autocomplete and allow to select application/json', async () => { + await selectCustomHttpsDestinationType(); - it('should not enable Create Destination button', async () => { - server.use( - http.post('*/monitor/streams/destinations/verify', () => { - verifyDestinationSpy(); - return HttpResponse.error(); - }), - http.get('*/profile', () => { - return HttpResponse.json(profileFactory.build()); - }) - ); + const contentTypeAutocomplete = screen.getByLabelText('Content Type'); + expect(contentTypeAutocomplete).toHaveValue(''); - renderDestinationCreate(flags); + await userEvent.click(contentTypeAutocomplete); + const jsonOption = await screen.findByText('application/json'); + await userEvent.click(jsonOption); + + expect(contentTypeAutocomplete).toHaveValue('application/json'); + }); + + it('should render Content Type autocomplete and allow to select application/json; charset=utf-8', async () => { + await selectCustomHttpsDestinationType(); + + const contentTypeAutocomplete = screen.getByLabelText('Content Type'); + + await userEvent.click(contentTypeAutocomplete); + const jsonUtf8Option = await screen.findByText( + 'application/json; charset=utf-8' + ); + await userEvent.click(jsonUtf8Option); + + expect(contentTypeAutocomplete).toHaveValue( + 'application/json; charset=utf-8' + ); + }); + + describe('Custom Headers', () => { + it('should add a custom header when clicking Add Custom Header button and allow typing in Custom Header fields', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const headerValueInput = screen.getByLabelText('Value'); + expect(headerValueInput).toBeInTheDocument(); + + await userEvent.type(headerNameInput, 'X-Custom-Header'); + expect(headerNameInput).toHaveValue('X-Custom-Header'); + + await userEvent.type(headerValueInput, 'custom-value'); + expect(headerValueInput).toHaveValue('custom-value'); + }); - const testConnectionButton = screen.getByRole('button', { - name: testConnectionButtonText, + it('should update custom header title when Name is typed', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + screen.getByText('Custom Header 1'); + + const headerNameInput = screen.getByLabelText('Name'); + await userEvent.type(headerNameInput, 'Authorization'); + + expect( + screen.queryByText('Custom Header 1') + ).not.toBeInTheDocument(); + screen.getByText('Authorization'); + }); + + it('should remove custom header when clicking close button', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: '' }); + await userEvent.click(closeButton); + + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + }); + + it('should allow adding multiple custom headers', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + + await userEvent.click(addCustomHeaderButton); + screen.getByText('Custom Header 1'); + + await userEvent.click(addCustomHeaderButton); + expect(screen.getByText('Custom Header 2')).toBeInTheDocument(); + }); }); - const createDestinationButton = screen.getByRole('button', { - name: createDestinationButtonText, + }); + + describe('given Test Connection and Create Destination buttons', () => { + const fillOutCustomHttpsForm = async () => { + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + const destinationNameInput = + screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test'); + const endpointUrlInput = screen.getByLabelText('Endpoint URL'); + await userEvent.type(endpointUrlInput, 'https://test-endpoint.com'); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Create Destination button and perform proper call when it's clicked", async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json({}); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutCustomHttpsForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(createDestinationButton).toBeEnabled(); + }); + + await userEvent.click(createDestinationButton); + expect(createDestinationSpy).toHaveBeenCalled(); + }); }); - await fillOutForm(); - expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); - expect(verifyDestinationSpy).toHaveBeenCalled(); - expect(createDestinationButton).toBeDisabled(); + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Create Destination button', async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutCustomHttpsForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + expect(createDestinationButton).toBeDisabled(); + }); + }); }); }); }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx index 11796efa544..bceccd99968 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx @@ -52,7 +52,10 @@ export const DestinationCreate = () => { const formValues = form.getValues(); const destination: CreateDestinationPayload = { ...formValues, - details: getDestinationPayloadDetails(formValues.details), + details: getDestinationPayloadDetails( + formValues.details, + formValues.type + ), }; createDestination(destination) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx index 4e169f7e167..de95ffff49e 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -7,14 +7,14 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { DestinationEdit } from 'src/features/Delivery/Destinations/DestinationForm/DestinationEdit'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; const destinationId = 123; -const mockDestination = destinationFactory.build({ +const mockDestination = akamaiObjectStorageDestinationFactory.build({ id: destinationId, label: `Destination ${destinationId}`, }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index 6d30c3cb7a3..f29decb6f2e 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -83,7 +83,10 @@ export const DestinationEdit = () => { const destination: UpdateDestinationPayloadWithId = { id: destinationId, ...omitProps(formValues, ['type']), - details: getDestinationPayloadDetails(formValues.details), + details: getDestinationPayloadDetails( + formValues.details, + formValues.type + ), }; updateDestination(destination) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index af3bd113a29..67e1bba84bf 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -1,4 +1,9 @@ -import { authenticationType, destinationType } from '@linode/api-v4'; +import { + authenticationType, + dataCompressionType, + type DestinationType, + destinationType, +} from '@linode/api-v4'; import { Autocomplete, Paper, TextField } from '@linode/ui'; import { capitalize, scrollErrorIntoViewV2 } from '@linode/utilities'; import Grid from '@mui/material/Grid'; @@ -29,6 +34,23 @@ interface DestinationFormProps { onSubmit: SubmitHandler; } +const customHttpsDetailsControlPaths = { + authenticationType: 'details.authentication.type', + basicAuthenticationPassword: + 'details.authentication.details.basic_authentication_password', + basicAuthenticationUser: + 'details.authentication.details.basic_authentication_user', + clientCaCertificate: + 'details.client_certificate_details.client_ca_certificate', + clientCertificate: 'details.client_certificate_details.client_certificate', + clientPrivateKey: 'details.client_certificate_details.client_private_key', + tlsHostname: 'details.client_certificate_details.tls_hostname', + contentType: 'details.content_type', + customHeaders: 'details.custom_headers', + dataCompression: 'details.data_compression', + endpointUrl: 'details.endpoint_url', +} as const; + export const DestinationForm = (props: DestinationFormProps) => { const { mode, isSubmitting, onSubmit } = props; @@ -41,7 +63,7 @@ export const DestinationForm = (props: DestinationFormProps) => { } = useVerifyDestination(); const formRef = React.useRef(null); - const { control, handleSubmit, setValue } = + const { control, handleSubmit, getValues, reset } = useFormContext(); const destination = useWatch({ control, @@ -51,6 +73,27 @@ export const DestinationForm = (props: DestinationFormProps) => { setDestinationVerified(false); }, [destination, setDestinationVerified]); + const resetForm = (destType: DestinationType) => { + const currentValues = getValues(); + const newDestinationDetails = + destType === destinationType.AkamaiObjectStorage + ? { + path: '', + } + : { + authentication: { + type: authenticationType.None, + }, + data_compression: dataCompressionType.Gzip, + }; + + reset({ + ...currentValues, + type: destType, + details: newDestinationDetails, + }); + }; + return (
@@ -66,12 +109,7 @@ export const DestinationForm = (props: DestinationFormProps) => { label="Destination Type" onBlur={field.onBlur} onChange={(_, { value }) => { - if (value === destinationType.CustomHttps) { - setValue( - 'details.authentication.type', - authenticationType.None - ); - } + resetForm(value as DestinationType); field.onChange(value); }} options={destinationTypeOptions} @@ -112,6 +150,7 @@ export const DestinationForm = (props: DestinationFormProps) => { {isACLPLogsCustomHttpsEnabled && destination.type === destinationType.CustomHttps && ( diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx index 90f3dda8a8b..3921315e7f0 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { DestinationsLanding } from 'src/features/Delivery/Destinations/DestinationsLanding'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; @@ -36,8 +36,11 @@ vi.mock('@linode/queries', async () => { }; }); -const destination = destinationFactory.build({ id: 1 }); -const destinations = [destination, ...destinationFactory.buildList(30)]; +const destination = akamaiObjectStorageDestinationFactory.build({ id: 1 }); +const destinations = [ + destination, + ...akamaiObjectStorageDestinationFactory.buildList(30), +]; describe('Destinations Landing Table', () => { const renderComponent = () => { diff --git a/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx b/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx new file mode 100644 index 00000000000..919256e4252 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx @@ -0,0 +1,144 @@ +import { + CloseIcon, + IconButton, + LinkButton, + Stack, + TextField, + Typography, +} from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import { useEffect } from 'react'; +import type { Control } from 'react-hook-form'; +import { + Controller, + useFieldArray, + useFormContext, + useWatch, +} from 'react-hook-form'; + +interface CustomHeaderTitleProps { + control: Control; + controlPath: string; + index: number; +} + +const CustomHeaderTitle = (props: CustomHeaderTitleProps) => { + const { control, controlPath, index } = props; + + const headerName = useWatch({ + control, + name: `${controlPath}[${index}].name`, + }); + + return ( + + {headerName?.length ? headerName : `Custom Header ${index + 1}`} + + ); +}; + +interface CustomHeadersProps { + controlPath: string; +} + +export const CustomHeaders = (props: CustomHeadersProps) => { + const { controlPath } = props; + + const { control, unregister } = useFormContext(); + + const { append, fields, remove } = useFieldArray({ + control, + name: controlPath, + }); + + useEffect(() => { + if (fields.length === 0) { + unregister(controlPath); + } + }, [fields, controlPath, unregister]); + + const addNewField = () => { + append({ name: '', value: '' }); + }; + + const removeField = (index: number) => { + remove(index); + if (fields.length === 1) { + unregister(controlPath); + } + }; + + return ( + <> + + {fields?.map((field, index) => ( + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + maxWidth: 416, + p: theme.spacingFunction(16), + })} + > + + + removeField(index)} sx={{ p: 0 }}> + + + + + ( + + )} + /> + ( + + )} + /> + + + ))} + + ({ + mt: theme.spacingFunction(16), + font: theme.tokens.alias.Typography.Label.Semibold.S, + })} + > + Add Custom Header + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx index bf0b76015cb..4c74e9fe6e8 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx @@ -77,7 +77,6 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ onChange={(value) => { field.onChange(value); }} - placeholder="Bucket" value={field.value} /> )} @@ -95,7 +94,6 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ label="Access Key ID" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Access Key ID" value={field.value} /> )} @@ -113,7 +111,6 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ label="Secret Access Key" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Secret Access Key" value={field.value} /> )} diff --git a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx index 1b47c5443f1..e9d89bb1be9 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx @@ -1,45 +1,44 @@ -import { Autocomplete, TextField } from '@linode/ui'; +import { Autocomplete, Divider, TextField, Typography } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; 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 { + getAuthenticationTypeOption, + getContentTypeOption, +} from 'src/features/Delivery/deliveryUtils'; +import { CustomHeaders } from 'src/features/Delivery/Shared/CustomHeaders'; +import { + authenticationTypeOptions, + contentTypeOptions, +} from 'src/features/Delivery/Shared/types'; import type { FormMode, FormType } from 'src/features/Delivery/Shared/types'; interface DestinationCustomHttpsDetailsFormProps { - controlPaths?: { + controlPaths: { authenticationType: string; basicAuthenticationPassword: string; basicAuthenticationUser: string; - clientCertificateDetails: string; + clientCaCertificate: string; + clientCertificate: string; + clientPrivateKey: string; contentType: string; customHeaders: string; dataCompression: string; endpointUrl: string; + tlsHostname: 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 { controlPaths } = props; + const theme = useTheme(); const { control } = useFormContext(); @@ -81,7 +80,6 @@ export const DestinationCustomHttpsDetailsForm = ( onChange={(value) => { field.onChange(value); }} - placeholder="Username" value={field.value} /> )} @@ -96,7 +94,6 @@ export const DestinationCustomHttpsDetailsForm = ( label="Password" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Password" value={field.value} /> )} @@ -115,11 +112,111 @@ export const DestinationCustomHttpsDetailsForm = ( onChange={(value) => { field.onChange(value); }} - placeholder="Endpoint URL" value={field.value} /> )} /> + + + Additional Options + + + Client Certificate  + + (optional) + + + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + + HTTPS Headers  + + (optional) + + + ( + { + field.onChange(value?.value || null); + }} + options={contentTypeOptions} + value={field.value ? getContentTypeOption(field.value) : null} + /> + )} + /> + ); }; diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index b2878c2d472..b9fe6c705c2 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -1,5 +1,6 @@ import { authenticationType, + contentType, destinationType, streamStatus, streamType, @@ -71,6 +72,17 @@ export const authenticationTypeOptions: AutocompleteOption[] = [ }, ]; +export const contentTypeOptions: AutocompleteOption[] = [ + { + value: contentType.Json, + label: contentType.Json, + }, + { + value: contentType.JsonUtf8, + label: contentType.JsonUtf8, + }, +]; + export type DestinationDetailsForm = | AkamaiObjectStorageDetailsExtended | CustomHTTPSDetails; diff --git a/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts b/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts index 0c321b299f4..93c49b82cee 100644 --- a/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts +++ b/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts @@ -17,7 +17,10 @@ export const useVerifyDestination = () => { try { const payload = { ...destination, - details: getDestinationPayloadDetails(destination.details), + details: getDestinationPayloadDetails( + destination.details, + destination.type + ), }; await callVerifyDestination(payload); 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 16e17569225..98575ca7838 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 @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -16,7 +16,7 @@ import type { Flags } from 'src/featureFlags'; const loadingTestId = 'circle-progress'; -const mockDestinations = destinationFactory +const mockDestinations = akamaiObjectStorageDestinationFactory .buildList(5) .map((destination: Destination) => { if (destination.id === 3) { @@ -167,10 +167,14 @@ describe('StreamFormDelivery', () => { await userEvent.click(destinationNameAutocomplete); // Select the "Destination 1" option - const firstDestination = await screen.findByText('Destination 1'); + const firstDestination = await screen.findByText( + 'Akamai Object Storage Destination 1' + ); await userEvent.click(firstDestination); - expect(destinationNameAutocomplete).toHaveValue('Destination 1'); + expect(destinationNameAutocomplete).toHaveValue( + 'Akamai Object Storage Destination 1' + ); }); it('should render Destination Name input and allow to add a new option', async () => { @@ -335,10 +339,14 @@ describe('StreamFormDelivery', () => { await userEvent.click(destinationNameAutocomplete); // Select the "Destination 3" option - const customHttpsDestination = await screen.findByText('Destination 3'); + const customHttpsDestination = await screen.findByText( + 'Akamai Object Storage Destination 3' + ); await userEvent.click(customHttpsDestination); - expect(destinationNameAutocomplete).toHaveValue('Destination 3'); + expect(destinationNameAutocomplete).toHaveValue( + 'Akamai Object Storage Destination 3' + ); }); it('should render Destination Name input and allow to add a new option', async () => { @@ -436,6 +444,185 @@ describe('StreamFormDelivery', () => { expect(endpointUrlInput.getAttribute('value')).toEqual('Test'); }); + + describe('Client Certificate fields', () => { + it('should render TLS Hostname input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const tlsHostnameInput = screen.getByLabelText('TLS Hostname'); + await userEvent.type(tlsHostnameInput, 'test'); + + expect(tlsHostnameInput).toHaveValue('test'); + }); + + it('should render CA Certificate input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const caCertificateInput = screen.getByLabelText('CA Certificate'); + await userEvent.type(caCertificateInput, 'test'); + + expect(caCertificateInput).toHaveValue('test'); + }); + + it('should render Client Certificate input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const clientCertificateInput = + screen.getByLabelText('Client Certificate'); + await userEvent.type(clientCertificateInput, 'test'); + + expect(clientCertificateInput).toHaveValue('test'); + }); + + it('should render Client Key input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const clientKeyInput = screen.getByLabelText('Client Key'); + await userEvent.type(clientKeyInput, 'test'); + + expect(clientKeyInput).toHaveValue('test'); + }); + }); + + describe('HTTPS Headers fields', () => { + it('should render Content Type autocomplete and allow to select application/json', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const contentTypeAutocomplete = + screen.getByLabelText('Content Type'); + expect(contentTypeAutocomplete).toHaveValue(''); + + await userEvent.click(contentTypeAutocomplete); + const jsonOption = await screen.findByText('application/json'); + await userEvent.click(jsonOption); + + expect(contentTypeAutocomplete).toHaveValue('application/json'); + }); + + it('should render Content Type autocomplete and allow to select application/json; charset=utf-8', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const contentTypeAutocomplete = + screen.getByLabelText('Content Type'); + + await userEvent.click(contentTypeAutocomplete); + const jsonUtf8Option = await screen.findByText( + 'application/json; charset=utf-8' + ); + await userEvent.click(jsonUtf8Option); + + expect(contentTypeAutocomplete).toHaveValue( + 'application/json; charset=utf-8' + ); + }); + + describe('Custom Headers', () => { + const addCustomHeaderButtonText = 'Add Custom Header'; + + it('should add a custom header when clicking Add Custom Header button and allow typing in Custom Header fields', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const headerValueInput = screen.getByLabelText('Value'); + expect(headerValueInput).toBeInTheDocument(); + + await userEvent.type(headerNameInput, 'X-Custom-Header'); + expect(headerNameInput).toHaveValue('X-Custom-Header'); + + await userEvent.type(headerValueInput, 'custom-value'); + expect(headerValueInput).toHaveValue('custom-value'); + }); + + it('should update custom header title when Name is typed', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + // Verify default title is shown initially + screen.getByText('Custom Header 1'); + + const headerNameInput = screen.getByLabelText('Name'); + await userEvent.type(headerNameInput, 'Authorization'); + + // Verify default title is replaced with the typed name + expect( + screen.queryByText('Custom Header 1') + ).not.toBeInTheDocument(); + screen.getByText('Authorization'); + }); + + it('should remove custom header when clicking close button', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: '' }); + await userEvent.click(closeButton); + + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + }); + + it('should allow adding multiple custom headers', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + + await userEvent.click(addCustomHeaderButton); + screen.getByText('Custom Header 1'); + + await userEvent.click(addCustomHeaderButton); + expect(screen.getByText('Custom Header 2')).toBeInTheDocument(); + }); + }); + }); }); }); }); 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 febc359fbc3..96168233764 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -1,4 +1,8 @@ -import { destinationType } from '@linode/api-v4'; +import { + authenticationType, + dataCompressionType, + destinationType, +} from '@linode/api-v4'; import { useAllDestinationsQuery } from '@linode/queries'; import { Autocomplete, @@ -54,6 +58,13 @@ const customHttpsDetailsControlPaths = { basicAuthenticationUser: 'destination.details.authentication.details.basic_authentication_user', clientCertificateDetails: 'destination.details.client_certificate_details', + clientCaCertificate: + 'destination.details.client_certificate_details.client_ca_certificate', + clientCertificate: + 'destination.details.client_certificate_details.client_certificate', + clientPrivateKey: + 'destination.details.client_certificate_details.client_private_key', + tlsHostname: 'destination.details.client_certificate_details.tls_hostname', contentType: 'destination.details.content_type', customHeaders: 'destination.details.custom_headers', dataCompression: 'destination.details.data_compression', @@ -70,7 +81,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const { isACLPLogsCustomHttpsEnabled } = useIsACLPLogsEnabled(); const theme = useTheme(); - const { control, setValue, clearErrors } = + const { control, setValue, getValues, reset } = useFormContext(); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); @@ -108,10 +119,40 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const findDestination = (id: number) => destinations?.find((destination) => destination.id === id); - const restDestinationForm = () => { - Object.values(akamaiObjectStorageDetailsControlPaths).forEach( - (controlPath) => setValue(controlPath, '') - ); + const resetDestinationForm = ( + destType: DestinationType, + destinationLabel?: null | string + ) => { + const currentValues = getValues(); + const newDestinationDetails = + destType === destinationType.AkamaiObjectStorage + ? { + path: '', + } + : { + authentication: { + type: authenticationType.None, + }, + client_certificate_details: { + client_ca_certificate: '', + client_certificate: '', + client_private_key: '', + tls_hostname: '', + }, + data_compression: dataCompressionType.Gzip, + }; + + reset({ + stream: { + ...currentValues.stream, + destinations: [], + }, + destination: { + ...currentValues.destination, + label: destinationLabel || '', + details: newDestinationDetails, + }, + }); }; const getDestinationForm = () => ( @@ -127,13 +168,9 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { label="Destination Type" onBlur={field.onBlur} onChange={(_, { value }) => { - if (value === destinationType.CustomHttps) { - setValue( - customHttpsDetailsControlPaths.authenticationType, - 'none' - ); - } field.onChange(value); + resetDestinationForm(value as DestinationType); + setCreatingNewDestination(false); }} options={destinationTypeOptions} textFieldProps={{ @@ -176,18 +213,21 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const id = newValue?.id; if (id === undefined && selectedDestinations.length > 0) { - restDestinationForm(); + resetDestinationForm( + selectedDestinationType, + (newValue?.label || newValue) as null | string + ); } - setValue('stream.destinations', id ? [id] : []); - const selectedDestination = id ? findDestination(id) : undefined; - if (selectedDestination) { - setValue('destination.details', { - ...selectedDestination.details, - access_key_secret: '', - }); - } else { - clearErrors('destination.details'); + if (id) { + setValue('stream.destinations', [id]); + const selectedDestination = findDestination(id); + if (selectedDestination) { + setValue('destination.details', { + ...selectedDestination.details, + access_key_secret: '', + }); + } } field.onChange(newValue?.label || newValue); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx index 388cbc762ea..11db3b2f5c5 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -4,14 +4,17 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { StreamCreate } from 'src/features/Delivery/Streams/StreamForm/StreamCreate'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; const mockDestinations = [ - destinationFactory.build({ id: 1, label: 'Destination 1' }), + akamaiObjectStorageDestinationFactory.build({ + id: 1, + label: 'Destination 1', + }), ]; describe('StreamCreate', () => { diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx index deab21e2c4f..7c4b9fb1dda 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -7,7 +7,10 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory, streamFactory } from 'src/factories'; +import { + akamaiObjectStorageDestinationFactory, + streamFactory, +} from 'src/factories'; import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; @@ -15,7 +18,9 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; const streamId = 123; -const mockDestinations = [destinationFactory.build({ id: 1 })]; +const mockDestinations = [ + akamaiObjectStorageDestinationFactory.build({ id: 1 }), +]; const mockStream = streamFactory.build({ id: streamId, label: `Stream ${streamId}`, @@ -60,7 +65,10 @@ describe('StreamEdit', () => { await waitFor(() => { assertInputHasValue('Destination Type', 'Akamai Object Storage'); }); - assertInputHasValue('Destination Name', 'Destination 1'); + assertInputHasValue( + 'Destination Name', + 'Akamai Object Storage Destination 1' + ); // Host: expect(screen.getByText('destinations-bucket-name.host.com')).toBeVisible(); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index 19b01c72a73..b0eb935c4f3 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -106,7 +106,10 @@ export const StreamForm = (props: StreamFormProps) => { try { const destinationPayload: CreateDestinationPayload = { ...destination, - details: getDestinationPayloadDetails(destination.details), + details: getDestinationPayloadDetails( + destination.details, + destination.type + ), }; const { id } = await createDestination(destinationPayload); destinationId = id; diff --git a/packages/manager/src/features/Delivery/deliveryUtils.test.ts b/packages/manager/src/features/Delivery/deliveryUtils.test.ts index 7df8135efc7..43891f9c7dd 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.test.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.test.ts @@ -1,20 +1,38 @@ -import { destinationType } from '@linode/api-v4'; +import { + authenticationType, + contentType, + destinationType, + streamType, +} from '@linode/api-v4'; import { expect } from 'vitest'; import { + getAuthenticationTypeOption, + getContentTypeOption, getDestinationPayloadDetails, getDestinationTypeOption, + getStreamTypeOption, } from 'src/features/Delivery/deliveryUtils'; -import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; +import { + authenticationTypeOptions, + contentTypeOptions, + destinationTypeOptions, + streamTypeOptions, +} from 'src/features/Delivery/Shared/types'; import type { AkamaiObjectStorageDetailsExtended, AkamaiObjectStorageDetailsPayload, + CustomHTTPSDetails, } from '@linode/api-v4'; describe('delivery utils functions', () => { describe('getDestinationTypeOption ', () => { - it('should return option object matching provided value', () => { + it('should return option for CustomHttps', () => { + const result = getDestinationTypeOption(destinationType.CustomHttps); + expect(result).toEqual(destinationTypeOptions[0]); + }); + it('should return option option for AkamaiObjectStorage', () => { const result = getDestinationTypeOption( destinationType.AkamaiObjectStorage ); @@ -22,35 +40,232 @@ describe('delivery utils functions', () => { }); it('should return undefined when no option is a match', () => { - const result = getDestinationTypeOption('random value'); - expect(result).toEqual(undefined); + const result = getDestinationTypeOption('invalid'); + expect(result).toBeUndefined(); + }); + }); + + describe('getStreamTypeOption', () => { + it('should return option for AuditLogs', () => { + const result = getStreamTypeOption(streamType.AuditLogs); + expect(result).toEqual(streamTypeOptions[0]); + }); + + it('should return option for LKEAuditLogs', () => { + const result = getStreamTypeOption(streamType.LKEAuditLogs); + expect(result).toEqual(streamTypeOptions[1]); + }); + + it('should return undefined when no option is a match', () => { + const result = getStreamTypeOption('invalid'); + expect(result).toBeUndefined(); + }); + }); + + describe('getAuthenticationTypeOption', () => { + it('should return option for basic authentication', () => { + const result = getAuthenticationTypeOption(authenticationType.Basic); + expect(result).toEqual(authenticationTypeOptions[0]); + }); + + it('should return option for none authentication', () => { + const result = getAuthenticationTypeOption(authenticationType.None); + expect(result).toEqual(authenticationTypeOptions[1]); + }); + + it('should return undefined when no option is a match', () => { + const result = getAuthenticationTypeOption('invalid'); + expect(result).toBeUndefined(); + }); + }); + + describe('getContentTypeOption', () => { + it('should return option for application/json', () => { + const result = getContentTypeOption(contentType.Json); + expect(result).toEqual(contentTypeOptions[0]); + }); + + it('should return option for application/json; charset=utf-8', () => { + const result = getContentTypeOption(contentType.JsonUtf8); + expect(result).toEqual(contentTypeOptions[1]); + }); + + it('should return undefined when no option is a match', () => { + const result = getContentTypeOption('invalid'); + expect(result).toBeUndefined(); }); }); - describe('getDestinationPayloadDetails ', () => { - const testDetails: AkamaiObjectStorageDetailsExtended = { - path: 'testpath', - access_key_id: 'keyId', - access_key_secret: 'secret', - bucket_name: 'name', - host: 'host', - }; + describe('given getDestinationPayloadDetails ', () => { + describe('and AkamaiObjectStorage destination type ', () => { + const baseAkamaiObjectStorageDetails: AkamaiObjectStorageDetailsExtended = + { + path: 'testpath', + access_key_id: 'keyId', + access_key_secret: 'secret', + bucket_name: 'name', + host: 'host', + }; + + it('should return payload details with path', () => { + const result = getDestinationPayloadDetails( + baseAkamaiObjectStorageDetails, + destinationType.AkamaiObjectStorage + ) as AkamaiObjectStorageDetailsPayload; - it('should return payload details with path', () => { - const result = getDestinationPayloadDetails( - testDetails - ) as AkamaiObjectStorageDetailsPayload; + expect(result.path).toEqual(baseAkamaiObjectStorageDetails.path); + }); - expect(result.path).toEqual(testDetails.path); + it('should return details without path property', () => { + const result = getDestinationPayloadDetails( + { + ...baseAkamaiObjectStorageDetails, + path: '', + }, + destinationType.AkamaiObjectStorage + ) as AkamaiObjectStorageDetailsPayload; + + expect(result.path).toEqual(undefined); + }); }); - it('should return details without path property', () => { - const result = getDestinationPayloadDetails({ - ...testDetails, - path: '', - }) as AkamaiObjectStorageDetailsPayload; + describe('and CustomHttps destination type', () => { + const baseCustomHTTPSDetails: CustomHTTPSDetails = { + authentication: { + type: 'none', + }, + data_compression: 'gzip', + endpoint_url: 'https://example.com', + }; + + it('should return details unchanged when all optional fields are populated', () => { + const details: CustomHTTPSDetails = { + ...baseCustomHTTPSDetails, + content_type: 'application/json', + client_certificate_details: { + client_ca_certificate: 'cert', + client_certificate: 'cert', + client_private_key: 'key', + tls_hostname: 'hostname', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ); + + expect(result).toEqual(details); + }); + + it('should omit content_type when it is null', () => { + const details: CustomHTTPSDetails = { + ...baseCustomHTTPSDetails, + content_type: null, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetails; + + expect(result.content_type).toBeUndefined(); + }); + + it('should omit client_certificate_details when all its properties are empty strings', () => { + const details: CustomHTTPSDetails = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: '', + client_certificate: '', + client_private_key: '', + tls_hostname: '', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetails; + + expect(result.client_certificate_details).toBeUndefined(); + }); + + it('should omit client_certificate_details when all its properties are not defined', () => { + const details: CustomHTTPSDetails = { + ...baseCustomHTTPSDetails, + client_certificate_details: {}, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetails; + + expect(result.client_certificate_details).toBeUndefined(); + }); + + it('should omit client_certificate_details when any of its properties is empty', () => { + const details: CustomHTTPSDetails = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: 'some-cert', + client_certificate: '', + client_private_key: 'key', + tls_hostname: 'hostname', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetails; + + expect(result.client_certificate_details).toBeUndefined(); + }); + + it('should keep client_certificate_details when all properties have values', () => { + const details: CustomHTTPSDetails = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: 'ca-cert', + client_certificate: 'cert', + client_private_key: 'key', + tls_hostname: 'hostname', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetails; + + expect(result.client_certificate_details).toBeDefined(); + expect(result.client_certificate_details).toEqual( + details.client_certificate_details + ); + }); + + it('should omit both content_type and client_certificate_details when both are empty', () => { + const details: CustomHTTPSDetails = { + ...baseCustomHTTPSDetails, + content_type: null, + client_certificate_details: { + client_ca_certificate: '', + client_certificate: '', + client_private_key: '', + tls_hostname: '', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetails; - expect(result.path).toEqual(undefined); + expect(result.content_type).toBeUndefined(); + expect(result.client_certificate_details).toBeUndefined(); + }); }); }); }); diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 53707dd0e97..f49600fd07d 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -1,6 +1,7 @@ import { type Destination, type DestinationDetailsPayload, + destinationType, isEmpty, type Stream, type StreamDetailsType, @@ -13,11 +14,13 @@ import { isFeatureEnabledV2 } from '@linode/utilities'; import { authenticationTypeOptions, + contentTypeOptions, destinationTypeOptions, streamTypeOptions, } from 'src/features/Delivery/Shared/types'; import { useFlags } from 'src/hooks/useFlags'; +import type { CustomHTTPSDetails, DestinationType } from '@linode/api-v4'; import type { AutocompleteOption, DestinationDetailsForm, @@ -69,6 +72,11 @@ export const getAuthenticationTypeOption = ( ({ value }) => value === authenticationTypeValue ); +export const getContentTypeOption = ( + contentTypeValue: string +): AutocompleteOption | undefined => + contentTypeOptions.find(({ value }) => value === contentTypeValue); + export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; export const getStreamPayloadDetails = ( @@ -91,9 +99,35 @@ export const getStreamPayloadDetails = ( }; export const getDestinationPayloadDetails = ( - details: DestinationDetailsForm + details: DestinationDetailsForm, + type: DestinationType ): DestinationDetailsPayload => { - if ('path' in details && details.path === '') { + if (type === destinationType.CustomHttps) { + const propsToRemove: any[] = []; + const customHTTPSDetails = details as CustomHTTPSDetails; + + if (!customHTTPSDetails.content_type) { + propsToRemove.push('content_type'); + } + + if (customHTTPSDetails.client_certificate_details) { + const certDetails = customHTTPSDetails.client_certificate_details; + const shouldRemoveCertDetails = [ + certDetails.client_ca_certificate, + certDetails.client_certificate, + certDetails.client_private_key, + certDetails.tls_hostname, + ].some((val) => !val); + + if (shouldRemoveCertDetails) { + propsToRemove.push('client_certificate_details'); + } + } + + if (propsToRemove.length > 0) { + return omitProps(customHTTPSDetails, propsToRemove) as CustomHTTPSDetails; + } + } else if ('path' in details && details.path === '') { return omitProps(details, ['path']); } diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts index 782cb73fbc2..c8373734b35 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts @@ -3,7 +3,11 @@ import { omitProps } from '@linode/ui'; import { DateTime } from 'luxon'; import { http } from 'msw'; -import { destinationFactory, streamFactory } from 'src/factories'; +import { + akamaiObjectStorageDestinationFactory, + customHttpsDestinationFactory, + streamFactory, +} from 'src/factories'; import { mswDB } from 'src/mocks/indexedDB'; import { queueEvents } from 'src/mocks/utilities/events'; import { @@ -223,22 +227,40 @@ export const createDestinations = (mockState: MockState) => [ request, }): Promise> => { const payload: CreateDestinationPayload = await request.clone().json(); - const details = omitProps( - payload.details as AkamaiObjectStorageDetailsPayload, - ['access_key_secret'] - ); - const destination = destinationFactory.build({ - label: payload.label, - type: payload.type, - details: { - ...details, - ...(payload.type === destinationType.AkamaiObjectStorage - ? { path: (details as AkamaiObjectStorageDetails).path ?? null } - : {}), - }, - created: DateTime.now().toISO(), - updated: DateTime.now().toISO(), - }); + const { label, type } = payload; + const details = + type === destinationType.AkamaiObjectStorage + ? omitProps(payload.details as AkamaiObjectStorageDetailsPayload, [ + 'access_key_secret', + ]) + : payload.details; + + const created = DateTime.now().toISO(); + const updated = DateTime.now().toISO(); + + const destination = + type === destinationType.AkamaiObjectStorage + ? akamaiObjectStorageDestinationFactory.build({ + label, + type, + details: { + ...details, + ...{ + path: (details as AkamaiObjectStorageDetails).path ?? null, + }, + }, + created, + updated, + }) + : customHttpsDestinationFactory.build({ + label, + type, + details: { + ...details, + }, + created, + updated, + }); await mswDB.add('destinations', destination, mockState); diff --git a/packages/validation/.changeset/pr-13331-changed-1769526857280.md b/packages/validation/.changeset/pr-13331-changed-1769526857280.md new file mode 100644 index 00000000000..770325ff543 --- /dev/null +++ b/packages/validation/.changeset/pr-13331-changed-1769526857280.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Adjust Custom HTTPS Destination validation: certificate details, custom headers, content type ([#13331](https://github.com/linode/manager/pull/13331)) diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index 21346cb1932..72778ba8f0b 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -56,7 +56,7 @@ const clientCertificateDetailsSchema = object({ }).test( 'all-or-nothing-cert-details', 'If any certificate detail is provided, all are required.', - (value, context) => { + function (value, context) { if (!value) { return true; } @@ -85,36 +85,36 @@ const clientCertificateDetailsSchema = object({ if (!hasValue(tls_hostname)) { errors.push( context.createError({ - path: 'tls_hostname', + path: `${this.path}.tls_hostname`, message: - 'TLS Hostname is required when other certificate details are provided.', + 'TLS Hostname is required when other Client Certificate details are provided.', }), ); } if (!hasValue(client_ca_certificate)) { errors.push( context.createError({ - path: 'client_ca_certificate', + path: `${this.path}.client_ca_certificate`, message: - 'CA Certificate is required when other certificate details are provided.', + 'CA Certificate is required when other Client Certificate details are provided.', }), ); } if (!hasValue(client_certificate)) { errors.push( context.createError({ - path: 'client_certificate', + path: `${this.path}.client_certificate`, message: - 'Client Certificate is required when other certificate details are provided.', + 'Client Certificate is required when other Client Certificate details are provided.', }), ); } if (!hasValue(client_private_key)) { errors.push( context.createError({ - path: 'client_private_key', + path: `${this.path}.client_private_key`, message: - 'Client Key is required when other certificate details are provided.', + 'Client Key is required when other Client Certificate details are provided.', }), ); } @@ -124,8 +124,12 @@ const clientCertificateDetailsSchema = object({ ); const customHeaderSchema = object({ - name: string().max(maxLength, maxLengthMessage).required(), - value: string().max(maxLength, maxLengthMessage).required(), + name: string() + .max(maxLength, maxLengthMessage) + .required('Custom Header Name is required.'), + value: string() + .max(maxLength, maxLengthMessage) + .required('Custom Header Value is required'), }); const customHTTPSDetailsSchema = object({ @@ -133,7 +137,8 @@ const customHTTPSDetailsSchema = object({ client_certificate_details: clientCertificateDetailsSchema.optional(), content_type: string() .oneOf(['application/json', 'application/json; charset=utf-8']) - .required('Content Type is required.'), + .nullable() + .optional(), custom_headers: array().of(customHeaderSchema).min(1).optional(), data_compression: string().oneOf(['gzip', 'None']).required(), endpoint_url: string()