diff --git a/web/package.json b/web/package.json index 9ff6caf15..ee0e31102 100644 --- a/web/package.json +++ b/web/package.json @@ -51,7 +51,7 @@ "@codemirror/view": "^6.9.6", "@harnessio/design-system": "^2.1.1", "@harnessio/icons": "^2.1.9", - "@harnessio/react-har-service-client": "^0.10.0", + "@harnessio/react-har-service-client": "^0.12.0", "@harnessio/react-ssca-manager-client": "^0.65.0", "@harnessio/uicore": "^4.1.2", "@tanstack/react-query": "4.20.4", diff --git a/web/src/ar/MFEAppTypes.ts b/web/src/ar/MFEAppTypes.ts index 765f5ae5a..d5b605618 100644 --- a/web/src/ar/MFEAppTypes.ts +++ b/web/src/ar/MFEAppTypes.ts @@ -133,5 +133,6 @@ export interface MFEAppProps { } export enum FeatureFlags { - HAR_TRIGGERS = 'HAR_TRIGGERS' + HAR_TRIGGERS = 'HAR_TRIGGERS', + HAR_NPM_PACKAGE_TYPE_ENABLED = 'HAR_NPM_PACKAGE_TYPE_ENABLED' } diff --git a/web/src/ar/hooks/useGetRepositoryTypes.ts b/web/src/ar/hooks/useGetRepositoryTypes.ts index 92306ab5a..89e50df85 100644 --- a/web/src/ar/hooks/useGetRepositoryTypes.ts +++ b/web/src/ar/hooks/useGetRepositoryTypes.ts @@ -15,7 +15,7 @@ */ import type { IconName } from '@harnessio/icons' -import type { FeatureFlags } from '@ar/MFEAppTypes' +import { FeatureFlags } from '@ar/MFEAppTypes' import type { StringsMap } from '@ar/frameworks/strings' import { RepositoryPackageType } from '@ar/common/types' import { useFeatureFlags } from './useFeatureFlag' @@ -70,6 +70,7 @@ const RepositoryTypes: RepositoryTypeListItem[] = [ value: RepositoryPackageType.NPM, icon: 'npm-repository-type', tooltip: 'Coming Soon!', + featureFlag: FeatureFlags.HAR_NPM_PACKAGE_TYPE_ENABLED, disabled: true }, { diff --git a/web/src/ar/hooks/useGetUpstreamRepositoryPackageTypes.ts b/web/src/ar/hooks/useGetUpstreamRepositoryPackageTypes.ts index e054e58e0..b5fad67e0 100644 --- a/web/src/ar/hooks/useGetUpstreamRepositoryPackageTypes.ts +++ b/web/src/ar/hooks/useGetUpstreamRepositoryPackageTypes.ts @@ -16,7 +16,7 @@ import type { IconName } from '@harnessio/icons' -import type { FeatureFlags } from '@ar/MFEAppTypes' +import { FeatureFlags } from '@ar/MFEAppTypes' import type { StringsMap } from '@ar/frameworks/strings' import { UpstreamProxyPackageType } from '@ar/pages/upstream-proxy-details/types' @@ -61,5 +61,13 @@ export const UpstreamProxyPackageTypeList: UpstreamRepositoryPackageTypeListItem label: 'repositoryTypes.maven', value: UpstreamProxyPackageType.MAVEN, icon: 'maven-repository-type' + }, + { + label: 'repositoryTypes.npm', + value: UpstreamProxyPackageType.NPM, + icon: 'npm-repository-type', + tooltip: 'Coming Soon!', + featureFlag: FeatureFlags.HAR_NPM_PACKAGE_TYPE_ENABLED, + disabled: true } ] diff --git a/web/src/ar/pages/repository-details/NpmRepository/NpmRepositoryType.tsx b/web/src/ar/pages/repository-details/NpmRepository/NpmRepositoryType.tsx new file mode 100644 index 000000000..e5caacc33 --- /dev/null +++ b/web/src/ar/pages/repository-details/NpmRepository/NpmRepositoryType.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2024 Harness, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import type { IconName } from '@harnessio/icons' + +import { RepositoryConfigType, RepositoryPackageType } from '@ar/common/types' +import UpstreamProxyActions from '@ar/pages/upstream-proxy-details/components/UpstreamProxyActions/UpstreamProxyActions' +import UpstreamProxyConfigurationForm from '@ar/pages/upstream-proxy-details/components/Forms/UpstreamProxyConfigurationForm' +import UpstreamProxyCreateFormContent from '@ar/pages/upstream-proxy-details/components/FormContent/UpstreamProxyCreateFormContent' +import UpstreamProxyDetailsHeader from '@ar/pages/upstream-proxy-details/components/UpstreamProxyDetailsHeader/UpstreamProxyDetailsHeader' +import { + type CreateRepositoryFormProps, + type RepositoryActionsProps, + type RepositoryConfigurationFormProps, + type RepositoryDetailsHeaderProps, + RepositoryStep, + type RepositoySetupClientProps +} from '@ar/frameworks/RepositoryStep/Repository' +import { + UpstreamProxyAuthenticationMode, + type UpstreamRegistryRequest, + UpstreamRepositoryURLInputSource +} from '@ar/pages/upstream-proxy-details/types' + +import type { Repository, VirtualRegistryRequest } from '../types' +import RepositoryActions from '../components/Actions/RepositoryActions' +import RedirectPageView from '../components/RedirectPageView/RedirectPageView' +import SetupClientContent from '../components/SetupClientContent/SetupClientContent' +import RepositoryConfigurationForm from '../components/Forms/RepositoryConfigurationForm' +import RepositoryCreateFormContent from '../components/FormContent/RepositoryCreateFormContent' +import RepositoryDetailsHeader from '../components/RepositoryDetailsHeader/RepositoryDetailsHeader' + +export class NpmRepositoryType extends RepositoryStep { + protected packageType = RepositoryPackageType.NPM + protected repositoryName = 'NPM Repository' + protected repositoryIcon: IconName = 'npm-repository-type' + protected supportedScanners = [] + protected supportsUpstreamProxy = true + protected supportedUpstreamURLSources = [ + UpstreamRepositoryURLInputSource.NpmJS, + UpstreamRepositoryURLInputSource.Custom + ] + + protected defaultValues: VirtualRegistryRequest = { + packageType: RepositoryPackageType.NPM, + identifier: '', + config: { + type: RepositoryConfigType.VIRTUAL + }, + scanners: [] + } + + protected defaultUpstreamProxyValues: UpstreamRegistryRequest = { + packageType: RepositoryPackageType.NPM, + identifier: '', + config: { + type: RepositoryConfigType.UPSTREAM, + source: UpstreamRepositoryURLInputSource.NpmJS, + authType: UpstreamProxyAuthenticationMode.ANONYMOUS, + url: '' + }, + cleanupPolicy: [], + scanners: [] + } + + renderCreateForm(props: CreateRepositoryFormProps): JSX.Element { + const { type } = props + if (type === RepositoryConfigType.VIRTUAL) { + return + } else { + return + } + } + + renderCofigurationForm(props: RepositoryConfigurationFormProps): JSX.Element { + const { type } = props + if (type === RepositoryConfigType.VIRTUAL) { + return + } else { + return + } + } + + renderActions(props: RepositoryActionsProps): JSX.Element { + if (props.type === RepositoryConfigType.VIRTUAL) { + return + } + return + } + + renderSetupClient(props: RepositoySetupClientProps): JSX.Element { + const { repoKey, onClose, artifactKey, versionKey } = props + return ( + + ) + } + + renderRepositoryDetailsHeader(props: RepositoryDetailsHeaderProps): JSX.Element { + const { type } = props + if (type === RepositoryConfigType.VIRTUAL) { + return + } else { + return + } + } + + renderRedirectPage(): JSX.Element { + return + } +} diff --git a/web/src/ar/pages/repository-details/NpmRepository/__tests__/CreateNpmRegistry.test.tsx b/web/src/ar/pages/repository-details/NpmRepository/__tests__/CreateNpmRegistry.test.tsx new file mode 100644 index 000000000..74de8a59a --- /dev/null +++ b/web/src/ar/pages/repository-details/NpmRepository/__tests__/CreateNpmRegistry.test.tsx @@ -0,0 +1,244 @@ +/* + * Copyright 2024 Harness, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import userEvent from '@testing-library/user-event' +import { fireEvent, getByTestId, getByText, render, screen, waitFor } from '@testing-library/react' + +import ArTestWrapper from '@ar/utils/testUtils/ArTestWrapper' +import RepositoryListPage from '@ar/pages/repository-list/RepositoryListPage' + +import { queryByNameAttribute } from 'utils/test/testUtils' +import { MockGetNpmRegistryResponseWithAllData } from './__mockData__' +import '../../RepositoryFactory' + +const createRegistryFn = jest.fn().mockImplementation(() => Promise.resolve(MockGetNpmRegistryResponseWithAllData)) +const showSuccessToast = jest.fn() +const showErrorToast = jest.fn() +const mockHistoryPush = jest.fn() + +jest.mock('@harnessio/uicore', () => ({ + ...jest.requireActual('@harnessio/uicore'), + useToaster: jest.fn().mockImplementation(() => ({ + showSuccess: showSuccessToast, + showError: showErrorToast, + clear: jest.fn() + })) +})) + +// eslint-disable-next-line jest-no-mock +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush + }) +})) + +jest.mock('@harnessio/react-har-service-client', () => ({ + useGetAllRegistriesQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + data: { content: { data: { registries: [] }, status: 'SUCCESS' } }, + refetch: jest.fn(), + error: null + })), + useCreateRegistryMutation: jest.fn().mockImplementation(() => ({ + mutateAsync: createRegistryFn + })) +})) + +describe('Verify create npm registry flow', () => { + test('Verify Modal header', async () => { + const { container } = render( + + + + ) + + const pageSubHeader = getByTestId(container, 'page-subheader') + const createRegistryButton = getByText(pageSubHeader, 'repositoryList.newRepository') + expect(createRegistryButton).toBeInTheDocument() + await userEvent.click(createRegistryButton) + + const modal = document.getElementsByClassName('bp3-dialog')[0] + expect(modal).toBeInTheDocument() + + const dialogHeader = screen.getByTestId('modaldialog-header') + expect(dialogHeader).toHaveTextContent('repositoryDetails.repositoryForm.modalTitle') + expect(dialogHeader).toHaveTextContent('repositoryDetails.repositoryForm.modalSubTitle') + + const closeButton = modal.querySelector('button[aria-label="Close"]') + expect(closeButton).toBeInTheDocument() + await userEvent.click(closeButton!) + + expect(modal).not.toBeInTheDocument() + }) + + test('verify registry type selector', async () => { + const { container } = render( + + + + ) + + const pageSubHeader = getByTestId(container, 'page-subheader') + const createRegistryButton = getByText(pageSubHeader, 'repositoryList.newRepository') + expect(createRegistryButton).toBeInTheDocument() + await userEvent.click(createRegistryButton) + + const modal = document.getElementsByClassName('bp3-dialog')[0] + expect(modal).toBeInTheDocument() + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + expect(dialogBody).toHaveTextContent('repositoryDetails.repositoryForm.selectRepoType') + const registryTypeOption = dialogBody.querySelector('input[type="checkbox"][name=packageType][value="NPM"]') + expect(registryTypeOption).not.toBeDisabled() + fireEvent.change(registryTypeOption!, { target: { checked: true } }) + expect(registryTypeOption).toBeChecked() + }) + + test('verify npm registry create form with success scenario', async () => { + const { container } = render( + + + + ) + + const pageSubHeader = getByTestId(container, 'page-subheader') + const createRegistryButton = getByText(pageSubHeader, 'repositoryList.newRepository') + expect(createRegistryButton).toBeInTheDocument() + await userEvent.click(createRegistryButton) + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + expect(dialogBody).toHaveTextContent('repositoryDetails.repositoryForm.selectRepoType') + const registryTypeOption = dialogBody.querySelector('input[type="checkbox"][name=packageType][value="NPM"]') + expect(registryTypeOption).not.toBeDisabled() + await userEvent.click(registryTypeOption!) + fireEvent.change(registryTypeOption!, { target: { checked: true } }) + expect(registryTypeOption).toBeChecked() + + expect(dialogBody).toHaveTextContent('repositoryDetails.repositoryForm.title') + + const formData = MockGetNpmRegistryResponseWithAllData.content.data + const nameField = queryByNameAttribute('identifier', dialogBody) + expect(nameField).toBeInTheDocument() + expect(nameField).not.toBeDisabled() + fireEvent.change(nameField!, { target: { value: formData.identifier } }) + + const descriptionEditButton = getByTestId(dialogBody, 'description-edit') + expect(descriptionEditButton).toBeInTheDocument() + await userEvent.click(descriptionEditButton) + const descriptionField = queryByNameAttribute('description', dialogBody) + expect(descriptionField).toBeInTheDocument() + expect(descriptionField).not.toBeDisabled() + fireEvent.change(descriptionField!, { target: { value: formData.description } }) + + const dialogFooter = screen.getByTestId('modaldialog-footer') + expect(dialogFooter).toBeInTheDocument() + const createButton = dialogFooter.querySelector( + 'button[type="submit"][aria-label="repositoryDetails.repositoryForm.create"]' + ) + expect(createButton).toBeInTheDocument() + await userEvent.click(createButton!) + + await waitFor(() => { + expect(createRegistryFn).toHaveBeenCalledWith({ + body: { + cleanupPolicy: [], + config: { type: 'VIRTUAL', upstreamProxies: [] }, + description: 'custom description', + identifier: 'npm-repo', + packageType: 'NPM', + parentRef: 'undefined', + scanners: [] + }, + queryParams: { space_ref: 'undefined' } + }) + expect(showSuccessToast).toHaveBeenCalledWith('repositoryDetails.repositoryForm.repositoryCreated') + expect(mockHistoryPush).toHaveBeenCalledWith('/registries/npm-repo/configuration') + }) + }) + + test('verify npm registry create form with failure scenario', async () => { + createRegistryFn.mockImplementation(() => Promise.reject({ message: 'error message' })) + const { container } = render( + + + + ) + + const pageSubHeader = getByTestId(container, 'page-subheader') + const createRegistryButton = getByText(pageSubHeader, 'repositoryList.newRepository') + expect(createRegistryButton).toBeInTheDocument() + await userEvent.click(createRegistryButton) + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + expect(dialogBody).toHaveTextContent('repositoryDetails.repositoryForm.selectRepoType') + const registryTypeOption = dialogBody.querySelector('input[type="checkbox"][name=packageType][value="NPM"]') + expect(registryTypeOption).not.toBeDisabled() + await userEvent.click(registryTypeOption!) + fireEvent.change(registryTypeOption!, { target: { checked: true } }) + expect(registryTypeOption).toBeChecked() + + expect(dialogBody).toHaveTextContent('repositoryDetails.repositoryForm.title') + + const formData = MockGetNpmRegistryResponseWithAllData.content.data + const nameField = queryByNameAttribute('identifier', dialogBody) + expect(nameField).toBeInTheDocument() + expect(nameField).not.toBeDisabled() + fireEvent.change(nameField!, { target: { value: formData.identifier } }) + + const dialogFooter = screen.getByTestId('modaldialog-footer') + expect(dialogFooter).toBeInTheDocument() + const createButton = dialogFooter.querySelector( + 'button[type="submit"][aria-label="repositoryDetails.repositoryForm.create"]' + ) + expect(createButton).toBeInTheDocument() + await userEvent.click(createButton!) + + await waitFor(() => { + expect(createRegistryFn).toHaveBeenCalledWith({ + body: { + cleanupPolicy: [], + config: { type: 'VIRTUAL', upstreamProxies: [] }, + identifier: 'npm-repo', + packageType: 'NPM', + parentRef: 'undefined', + scanners: [] + }, + queryParams: { space_ref: 'undefined' } + }) + expect(showErrorToast).toHaveBeenCalledWith('error message') + }) + }) +}) diff --git a/web/src/ar/pages/repository-details/NpmRepository/__tests__/CreateNpmUpstreamRegistry.test.tsx b/web/src/ar/pages/repository-details/NpmRepository/__tests__/CreateNpmUpstreamRegistry.test.tsx new file mode 100644 index 000000000..1eb42de78 --- /dev/null +++ b/web/src/ar/pages/repository-details/NpmRepository/__tests__/CreateNpmUpstreamRegistry.test.tsx @@ -0,0 +1,338 @@ +/* + * Copyright 2024 Harness, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@testing-library/react' +import type { Registry } from '@harnessio/react-har-service-client' + +import { Parent } from '@ar/common/types' +import ArTestWrapper from '@ar/utils/testUtils/ArTestWrapper' +import RepositoryListPage from '@ar/pages/repository-list/RepositoryListPage' + +import { MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData } from './__mockData__' +import upstreamProxyUtils from '../../__tests__/utils' +import '../../RepositoryFactory' + +const createRegistryFn = jest + .fn() + .mockImplementation(() => Promise.resolve(MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData)) +const showSuccessToast = jest.fn() +const showErrorToast = jest.fn() +const mockHistoryPush = jest.fn() + +jest.mock('@harnessio/uicore', () => ({ + ...jest.requireActual('@harnessio/uicore'), + useToaster: jest.fn().mockImplementation(() => ({ + showSuccess: showSuccessToast, + showError: showErrorToast, + clear: jest.fn() + })) +})) + +// eslint-disable-next-line jest-no-mock +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush + }) +})) + +jest.mock('@harnessio/react-har-service-client', () => ({ + useGetAllRegistriesQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + data: { content: { data: { registries: [] }, status: 'SUCCESS' } }, + refetch: jest.fn(), + error: null + })), + useCreateRegistryMutation: jest.fn().mockImplementation(() => ({ + mutateAsync: createRegistryFn + })) +})) + +describe('Verify create npm upstream registry flow', () => { + test('Verify Modal header', async () => { + const { container } = render( + + + + ) + + const modal = await upstreamProxyUtils.openModal(container) + + const dialogHeader = screen.getByTestId('modaldialog-header') + expect(dialogHeader).toHaveTextContent('upstreamProxyDetails.createForm.title') + + const closeButton = modal.querySelector('button[aria-label="Close"]') + expect(closeButton).toBeInTheDocument() + await userEvent.click(closeButton!) + + expect(modal).not.toBeInTheDocument() + }) + + test('verify registry type selector', async () => { + const { container } = render( + + + + ) + + await upstreamProxyUtils.openModal(container) + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + await upstreamProxyUtils.verifyPackageTypeSelector(dialogBody, 'NPM') + }) + + test('verify NPM registry create form with success scenario > Source as NpmJs > Anonymous', async () => { + const { container } = render( + + + + ) + + await upstreamProxyUtils.openModal(container) + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + await upstreamProxyUtils.verifyPackageTypeSelector(dialogBody, 'NPM') + + expect(dialogBody).toHaveTextContent('upstreamProxyDetails.form.title') + + const formData = MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data as Registry + + await upstreamProxyUtils.verifyUpstreamProxyCreateForm( + dialogBody, + formData, + 'NpmJs', + 'Anonymous', + 'NpmJs', + 'Anonymous' + ) + + const createButton = await upstreamProxyUtils.getSubmitButton() + await userEvent.click(createButton!) + + await waitFor(() => { + expect(createRegistryFn).toHaveBeenLastCalledWith({ + body: { + cleanupPolicy: [], + config: { auth: null, authType: 'Anonymous', source: 'NpmJs', type: 'UPSTREAM', url: '' }, + description: 'test description', + identifier: 'npm-up-repo', + packageType: 'NPM', + parentRef: 'undefined', + scanners: [] + }, + queryParams: { space_ref: 'undefined' } + }) + expect(showSuccessToast).toHaveBeenLastCalledWith( + 'upstreamProxyDetails.actions.createUpdateModal.createSuccessMessage' + ) + expect(mockHistoryPush).toHaveBeenLastCalledWith('/registries/npm-up-repo/configuration') + }) + }) + + test('verify NPM registry create form with success scenario > Source as NpmJs > UserPassword', async () => { + const { container } = render( + + + + ) + + await upstreamProxyUtils.openModal(container) + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + await upstreamProxyUtils.verifyPackageTypeSelector(dialogBody, 'NPM') + + expect(dialogBody).toHaveTextContent('upstreamProxyDetails.form.title') + + const formData = MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data as Registry + + await upstreamProxyUtils.verifyUpstreamProxyCreateForm( + dialogBody, + formData, + 'NpmJs', + 'UserPassword', + 'NpmJs', + 'Anonymous' + ) + + const createButton = await upstreamProxyUtils.getSubmitButton() + await userEvent.click(createButton!) + + await waitFor(() => { + expect(createRegistryFn).toHaveBeenLastCalledWith({ + body: { + cleanupPolicy: [], + config: { + auth: { authType: 'UserPassword', secretIdentifier: 'password', userName: 'username' }, + authType: 'UserPassword', + source: 'NpmJs', + type: 'UPSTREAM', + url: '' + }, + description: 'test description', + identifier: 'npm-up-repo', + packageType: 'NPM', + parentRef: 'undefined', + scanners: [] + }, + queryParams: { space_ref: 'undefined' } + }) + expect(showSuccessToast).toHaveBeenLastCalledWith( + 'upstreamProxyDetails.actions.createUpdateModal.createSuccessMessage' + ) + expect(mockHistoryPush).toHaveBeenLastCalledWith('/registries/npm-up-repo/configuration') + }) + }) + + test('verify npm registry create form with success scenario > Source as Custom > Anonymous', async () => { + const { container } = render( + + + + ) + + await upstreamProxyUtils.openModal(container) + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + await upstreamProxyUtils.verifyPackageTypeSelector(dialogBody, 'NPM') + + expect(dialogBody).toHaveTextContent('upstreamProxyDetails.form.title') + + const formData = MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data as Registry + + await upstreamProxyUtils.verifyUpstreamProxyCreateForm( + dialogBody, + formData, + 'Custom', + 'Anonymous', + 'NpmJs', + 'Anonymous' + ) + + const createButton = await upstreamProxyUtils.getSubmitButton() + await userEvent.click(createButton!) + + await waitFor(() => { + expect(createRegistryFn).toHaveBeenLastCalledWith({ + body: { + cleanupPolicy: [], + config: { + auth: null, + authType: 'Anonymous', + source: 'Custom', + type: 'UPSTREAM', + url: 'https://custom.docker.com' + }, + description: 'test description', + identifier: 'npm-up-repo', + packageType: 'NPM', + parentRef: 'undefined', + scanners: [] + }, + queryParams: { space_ref: 'undefined' } + }) + expect(showSuccessToast).toHaveBeenLastCalledWith( + 'upstreamProxyDetails.actions.createUpdateModal.createSuccessMessage' + ) + expect(mockHistoryPush).toHaveBeenLastCalledWith('/registries/npm-up-repo/configuration') + }) + }) + + test('verify NPM registry create form with success scenario > Source as Custom > Username Password', async () => { + const { container } = render( + + + + ) + + await upstreamProxyUtils.openModal(container) + + const dialogBody = screen.getByTestId('modaldialog-body') + expect(dialogBody).toBeInTheDocument() + + await upstreamProxyUtils.verifyPackageTypeSelector(dialogBody, 'NPM') + + expect(dialogBody).toHaveTextContent('upstreamProxyDetails.form.title') + + const formData = MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data as Registry + + await upstreamProxyUtils.verifyUpstreamProxyCreateForm( + dialogBody, + formData, + 'Custom', + 'UserPassword', + 'NpmJs', + 'Anonymous' + ) + + const createButton = await upstreamProxyUtils.getSubmitButton() + await userEvent.click(createButton!) + + await waitFor(() => { + expect(createRegistryFn).toHaveBeenLastCalledWith({ + body: { + cleanupPolicy: [], + config: { + auth: { authType: 'UserPassword', secretIdentifier: 'password', userName: 'username' }, + authType: 'UserPassword', + source: 'Custom', + type: 'UPSTREAM', + url: 'https://custom.docker.com' + }, + description: 'test description', + identifier: 'npm-up-repo', + packageType: 'NPM', + parentRef: 'undefined', + scanners: [] + }, + queryParams: { space_ref: 'undefined' } + }) + expect(showSuccessToast).toHaveBeenLastCalledWith( + 'upstreamProxyDetails.actions.createUpdateModal.createSuccessMessage' + ) + expect(mockHistoryPush).toHaveBeenLastCalledWith('/registries/npm-up-repo/configuration') + }) + }) +}) diff --git a/web/src/ar/pages/repository-details/NpmRepository/__tests__/EditNpmRegistry.test.tsx b/web/src/ar/pages/repository-details/NpmRepository/__tests__/EditNpmRegistry.test.tsx new file mode 100644 index 000000000..a4ebe66fb --- /dev/null +++ b/web/src/ar/pages/repository-details/NpmRepository/__tests__/EditNpmRegistry.test.tsx @@ -0,0 +1,367 @@ +/* + * Copyright 2024 Harness, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import userEvent from '@testing-library/user-event' +import { fireEvent, getByTestId, getByText, queryByTestId, render, waitFor } from '@testing-library/react' +import { useGetClientSetupDetailsQuery, useGetRegistryQuery } from '@harnessio/react-har-service-client' + +import { DEFAULT_DATE_TIME_FORMAT } from '@ar/constants' +import { getReadableDateTime } from '@ar/common/dateUtils' +import ArTestWrapper from '@ar/utils/testUtils/ArTestWrapper' +import { queryByNameAttribute } from 'utils/test/testUtils' + +import RepositoryDetailsPage from '../../RepositoryDetailsPage' +import { + MockGetNpmArtifactsByRegistryResponse, + MockGetNpmRegistryResponseWithAllData, + MockGetNpmSetupClientOnRegistryConfigPageResponse +} from './__mockData__' +import '../../RepositoryFactory' + +const modifyRepository = jest.fn().mockImplementation( + () => + new Promise(onSuccess => { + onSuccess({ content: { status: 'SUCCESS' } }) + }) +) + +const mockHistoryPush = jest.fn() +// eslint-disable-next-line jest-no-mock +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush + }) +})) + +jest.mock('@harnessio/react-har-service-client', () => ({ + useGetRegistryQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + refetch: jest.fn(), + error: false, + data: MockGetNpmRegistryResponseWithAllData + })), + useGetAllArtifactsByRegistryQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + refetch: jest.fn(), + error: false, + data: MockGetNpmArtifactsByRegistryResponse + })), + useGetClientSetupDetailsQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + refetch: jest.fn(), + error: false, + data: MockGetNpmSetupClientOnRegistryConfigPageResponse + })), + useDeleteRegistryMutation: jest.fn().mockImplementation(() => ({ + isLoading: false, + mutateAsync: jest.fn() + })), + useModifyRegistryMutation: jest.fn().mockImplementation(() => ({ + isLoading: false, + mutateAsync: modifyRepository + })), + useGetAllRegistriesQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + data: { content: { data: { registries: [] }, status: 'SUCCESS' } }, + refetch: jest.fn(), + error: null + })) +})) + +describe('Verify header section for NPM artifact registry', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('Verify breadcrumbs', async () => { + const { container } = render( + + + + ) + + const pageHeader = container.querySelector('div[data-testid=page-header]') + expect(pageHeader).toBeInTheDocument() + + const breadcrumbsSection = pageHeader?.querySelector('div[class*=PageHeader--breadcrumbsDiv--]') + expect(breadcrumbsSection).toBeInTheDocument() + + expect(breadcrumbsSection).toHaveTextContent('breadcrumbs.repositories') + }) + + test('Verify registry icon, registry name, tag, lables, description and last updated', async () => { + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'registry-header-container') + expect(pageHeader).toBeInTheDocument() + + expect(pageHeader?.querySelector('span[data-icon=npm-repository-type]')).toBeInTheDocument() + const data = MockGetNpmRegistryResponseWithAllData.content.data + + const title = getByTestId(container, 'registry-title') + expect(title).toHaveTextContent(data.identifier) + + const description = getByTestId(container, 'registry-description') + expect(description).toHaveTextContent(data.description) + + expect(pageHeader?.querySelector('svg[data-icon=tag]')).toBeInTheDocument() + + const lastModifiedAt = getByTestId(container, 'registry-last-modified-at') + expect(lastModifiedAt).toHaveTextContent(getReadableDateTime(Number(data.modifiedAt), DEFAULT_DATE_TIME_FORMAT)) + }) + + test('Verify registry setup client action', async () => { + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'registry-header-container') + const setupClientBtn = pageHeader.querySelector('button[aria-label="actions.setupClient"]') + expect(setupClientBtn).toBeInTheDocument() + await userEvent.click(setupClientBtn!) + + await waitFor(() => { + expect(useGetClientSetupDetailsQuery).toHaveBeenLastCalledWith({ + queryParams: { artifact: undefined, version: undefined }, + registry_ref: 'undefined/npm-repo/+' + }) + }) + }) + + test('Verify other registry actions', async () => { + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'registry-header-container') + const actions3DotsBtn = pageHeader.querySelector('span[data-icon=Options') + expect(actions3DotsBtn).toBeInTheDocument() + + await userEvent.click(actions3DotsBtn!) + const dialogs = document.getElementsByClassName('bp3-popover') + await waitFor(() => expect(dialogs).toHaveLength(1)) + const selectPopover = dialogs[0] as HTMLElement + + const items = selectPopover.getElementsByClassName('bp3-menu-item') + for (let idx = 0; idx < items.length; idx++) { + const actionItem = items[idx] + expect(actionItem.querySelector('span[data-icon=code-delete]')).toBeInTheDocument() + expect(actionItem).toHaveTextContent('actions.delete') + } + }) + + test('Verify tab selection status', async () => { + const { container } = render( + + + + ) + + const tabList = container.querySelector('div[role=tablist]') + expect(tabList).toBeInTheDocument() + + const artifactsTab = tabList?.querySelector('div[data-tab-id=packages][aria-selected=true]') + expect(artifactsTab).toBeInTheDocument() + + const configurationTab = tabList?.querySelector('div[data-tab-id=configuration][aria-selected=false]') + expect(configurationTab).toBeInTheDocument() + + await userEvent.click(configurationTab!) + expect(mockHistoryPush).toHaveBeenCalledWith('/registries/abcd/configuration') + }) +}) + +describe('Verify configuration form', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should render form correctly with all data prefilled', async () => { + const { container } = render( + + + + ) + + // Artifact registry defination section + const registryDefinitionSection = getByTestId(container, 'registry-definition') + const nameField = queryByNameAttribute('identifier', registryDefinitionSection) + expect(nameField).toBeInTheDocument() + expect(nameField).toBeDisabled() + expect(nameField).toHaveAttribute('value', MockGetNpmRegistryResponseWithAllData.content.data.identifier) + + const descriptionField = queryByNameAttribute('description', registryDefinitionSection) + expect(descriptionField).toBeInTheDocument() + expect(descriptionField).not.toBeDisabled() + expect(descriptionField).toHaveTextContent(MockGetNpmRegistryResponseWithAllData.content.data.description) + + const tags = registryDefinitionSection.querySelectorAll('div.bp3-tag-input-values .bp3-tag') + tags.forEach((each, idx) => { + expect(each).toHaveTextContent(MockGetNpmRegistryResponseWithAllData.content.data.labels[idx]) + }) + + // Security scan section + const securityScanSection = queryByTestId(container, 'security-section') + expect(securityScanSection).toBeInTheDocument() + + // artifact filtering rules + const filteringRulesSection = getByTestId(container, 'include-exclude-patterns-section') + expect(filteringRulesSection).toBeInTheDocument() + + const allowedPatternsSection = filteringRulesSection.querySelectorAll('div.bp3-form-group')[0] + const allowedPatterns = allowedPatternsSection.querySelectorAll('div.bp3-tag-input-values .bp3-tag') + allowedPatterns.forEach((each, idx) => { + expect(each).toHaveTextContent(MockGetNpmRegistryResponseWithAllData.content.data.allowedPattern[idx]) + }) + + const blockedPatternsSection = filteringRulesSection.querySelectorAll('div.bp3-form-group')[1] + const blockedPatterns = blockedPatternsSection.querySelectorAll('div.bp3-tag-input-values .bp3-tag') + blockedPatterns.forEach((each, idx) => { + expect(each).toHaveTextContent(MockGetNpmRegistryResponseWithAllData.content.data.blockedPattern[idx]) + }) + + // upstream proxy section + const upstreamProxySection = getByTestId(container, 'upstream-proxy-section') + expect(upstreamProxySection).toBeInTheDocument() + const selectedItemList = upstreamProxySection.querySelectorAll('ul[aria-label=orderable-list] .bp3-menu-item') + selectedItemList.forEach((each, idx) => { + expect(each).toHaveTextContent(MockGetNpmRegistryResponseWithAllData.content.data.config.upstreamProxies[idx]) + }) + + // cleanup policy section + const cleanupPoliciesSection = getByTestId(container, 'cleanup-policy-section') + expect(cleanupPoliciesSection).toBeInTheDocument() + const addCleanupPolicyBtn = cleanupPoliciesSection.querySelector( + 'a[role=button][aria-label="cleanupPolicy.addBtn"]' + ) + expect(addCleanupPolicyBtn).toBeInTheDocument() + expect(addCleanupPolicyBtn).toHaveAttribute('disabled', '') + + // action buttons + const saveBtn = container.querySelector('button[aria-label=save]') + expect(saveBtn).toBeDisabled() + + const discardBtn = container.querySelector('button[aria-label=discard]') + expect(discardBtn).toBeDisabled() + }) + + test('should able to submit the form with updated data', async () => { + const { container } = render( + + + + ) + + const descriptionField = queryByNameAttribute('description', container) + fireEvent.change(descriptionField!, { target: { value: 'updated description' } }) + expect(descriptionField).toHaveTextContent('updated description') + + const saveBtn = container.querySelector('button[aria-label=save]') + expect(saveBtn).not.toBeDisabled() + + const discardBtn = container.querySelector('button[aria-label=discard]') + expect(discardBtn).not.toBeDisabled() + + await userEvent.click(saveBtn!) + await waitFor(() => { + expect(modifyRepository).toHaveBeenCalledWith({ + body: { + ...MockGetNpmRegistryResponseWithAllData.content.data, + description: 'updated description' + }, + registry_ref: 'undefined/abcd/+' + }) + }) + }) + + test('should able to discard the changes', async () => { + const { container } = render( + + + + ) + + const descriptionField = queryByNameAttribute('description', container) + fireEvent.change(descriptionField!, { target: { value: 'updated description' } }) + expect(descriptionField).toHaveTextContent('updated description') + + const saveBtn = container.querySelector('button[aria-label=save]') + expect(saveBtn).not.toBeDisabled() + + const discardBtn = container.querySelector('button[aria-label=discard]') + expect(discardBtn).not.toBeDisabled() + + await userEvent.click(discardBtn!) + await waitFor(() => { + expect(saveBtn).toBeDisabled() + expect(discardBtn).toBeDisabled() + expect(descriptionField).toHaveTextContent(MockGetNpmRegistryResponseWithAllData.content.data.description) + }) + }) + + test('should render retry if failed to load get registry api', async () => { + const refetchFn = jest.fn() + ;(useGetRegistryQuery as jest.Mock).mockImplementation(() => ({ + isFetching: false, + isError: true, + error: { message: 'failed to load registry' }, + data: null, + refetch: refetchFn + })) + + const { container } = render( + + + + ) + + expect(getByText(container, 'failed to load registry')).toBeInTheDocument() + const retryBtn = container.querySelector('button[aria-label=Retry]') + expect(retryBtn).toBeInTheDocument() + await userEvent.click(retryBtn!) + await waitFor(() => { + expect(refetchFn).toHaveBeenCalled() + }) + }) +}) diff --git a/web/src/ar/pages/repository-details/NpmRepository/__tests__/EditNpmUpstreamRegistry.test.tsx b/web/src/ar/pages/repository-details/NpmRepository/__tests__/EditNpmUpstreamRegistry.test.tsx new file mode 100644 index 000000000..6dfbc3928 --- /dev/null +++ b/web/src/ar/pages/repository-details/NpmRepository/__tests__/EditNpmUpstreamRegistry.test.tsx @@ -0,0 +1,566 @@ +/* + * Copyright 2024 Harness, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react' +import userEvent from '@testing-library/user-event' +import { fireEvent, getByTestId, getByText, queryByTestId, render, waitFor } from '@testing-library/react' + +import { Parent } from '@ar/common/types' +import ArTestWrapper from '@ar/utils/testUtils/ArTestWrapper' + +import { queryByNameAttribute } from 'utils/test/testUtils' +import RepositoryDetailsPage from '../../RepositoryDetailsPage' +import { + MockGetNpmArtifactsByRegistryResponse, + MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData +} from './__mockData__' +import upstreamProxyUtils from '../../__tests__/utils' + +const modifyRepository = jest.fn().mockImplementation( + () => + new Promise(onSuccess => { + onSuccess({ content: { status: 'SUCCESS' } }) + }) +) + +const deleteRegistry = jest.fn().mockImplementation( + () => + new Promise(onSuccess => { + onSuccess({ content: { status: 'SUCCESS' } }) + }) +) + +const showSuccessToast = jest.fn() +const showErrorToast = jest.fn() + +jest.mock('@harnessio/uicore', () => ({ + ...jest.requireActual('@harnessio/uicore'), + useToaster: jest.fn().mockImplementation(() => ({ + showSuccess: showSuccessToast, + showError: showErrorToast, + clear: jest.fn() + })) +})) + +const mockHistoryPush = jest.fn() +// eslint-disable-next-line jest-no-mock +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush + }) +})) + +jest.mock('@harnessio/react-har-service-client', () => ({ + useGetRegistryQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + refetch: jest.fn(), + error: false, + data: MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData + })), + useGetAllArtifactsByRegistryQuery: jest.fn().mockImplementation(() => ({ + isFetching: false, + refetch: jest.fn(), + error: false, + data: MockGetNpmArtifactsByRegistryResponse + })), + useDeleteRegistryMutation: jest.fn().mockImplementation(() => ({ + isLoading: false, + mutateAsync: deleteRegistry + })), + useModifyRegistryMutation: jest.fn().mockImplementation(() => ({ + isLoading: false, + mutateAsync: modifyRepository + })) +})) + +describe('Verify header section for docker artifact registry', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('Verify breadcrumbs', async () => { + const { container } = render( + + + + ) + + const pageHeader = container.querySelector('div[data-testid=page-header]') + expect(pageHeader).toBeInTheDocument() + + const breadcrumbsSection = pageHeader?.querySelector('div[class*=PageHeader--breadcrumbsDiv--]') + expect(breadcrumbsSection).toBeInTheDocument() + + expect(breadcrumbsSection).toHaveTextContent('breadcrumbs.repositories') + }) + + test('Verify registry icon, registry name, tag, lables, description and last updated', async () => { + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'upstream-registry-header-container') + expect(pageHeader).toBeInTheDocument() + + expect(pageHeader?.querySelector('span[data-icon=npm-repository-type]')).toBeInTheDocument() + const data = MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data + + expect(pageHeader).toHaveTextContent(data.identifier) + + expect(pageHeader).toHaveTextContent('na') + }) + + test('Verify registry setup client action not visible', async () => { + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'upstream-registry-header-container') + const setupClientBtn = pageHeader.querySelector('button[aria-label="actions.setupClient"]') + expect(setupClientBtn).not.toBeInTheDocument() + }) + + test('Verify other registry actions', async () => { + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'upstream-registry-header-container') + const actions3DotsBtn = pageHeader.querySelector('span[data-icon=Options') + expect(actions3DotsBtn).toBeInTheDocument() + + await userEvent.click(actions3DotsBtn!) + const dialogs = document.getElementsByClassName('bp3-popover') + await waitFor(() => expect(dialogs).toHaveLength(1)) + const selectPopover = dialogs[0] as HTMLElement + + const items = selectPopover.getElementsByClassName('bp3-menu-item') + for (let idx = 0; idx < items.length; idx++) { + const actionItem = items[idx] + expect(actionItem.querySelector('span[data-icon=code-delete]')).toBeInTheDocument() + expect(actionItem).toHaveTextContent('actions.delete') + } + }) + + test('verify delete action: Success', async () => { + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'upstream-registry-header-container') + const actions3DotsBtn = pageHeader.querySelector('span[data-icon=Options') + expect(actions3DotsBtn).toBeInTheDocument() + await userEvent.click(actions3DotsBtn!) + const selectPopover = document.getElementsByClassName('bp3-popover')[0] as HTMLElement + + const items = selectPopover.getElementsByClassName('bp3-menu-item') + const actionItem = items[0] + expect(actionItem.querySelector('span[data-icon=code-delete]')).toBeInTheDocument() + expect(actionItem).toHaveTextContent('actions.delete') + + await userEvent.click(actionItem) + let deleteDialog = document.getElementsByClassName('bp3-dialog')[0] + expect(deleteDialog).toBeInTheDocument() + expect(deleteDialog).toHaveTextContent('upstreamProxyDetails.actions.delete.title') + expect(deleteDialog).toHaveTextContent('upstreamProxyDetails.actions.delete.contentText') + + const cancelButton = deleteDialog.querySelector('button[aria-label=cancel]') + expect(cancelButton).toBeInTheDocument() + await userEvent.click(cancelButton!) + expect(deleteDialog).not.toBeInTheDocument() + + await userEvent.click(actionItem!) + deleteDialog = document.getElementsByClassName('bp3-dialog')[0] + expect(deleteDialog).toBeInTheDocument() + const deleteBtn = deleteDialog.querySelector('button[aria-label=delete]') + expect(deleteBtn).toBeInTheDocument() + await userEvent.click(deleteBtn!) + + await waitFor(() => { + expect(deleteRegistry).toHaveBeenLastCalledWith({ registry_ref: 'undefined/npm-up-repo/+' }) + expect(mockHistoryPush).toHaveBeenCalledWith('/registries') + expect(showSuccessToast).toHaveBeenCalledWith('upstreamProxyDetails.actions.delete.repositoryDeleted') + }) + expect(deleteDialog).not.toBeInTheDocument() + }) + + test('verify delete action: Failure', async () => { + deleteRegistry.mockImplementationOnce( + () => + new Promise((_, onReject) => { + onReject({ message: 'error message' }) + }) + ) + const { container } = render( + + + + ) + const pageHeader = getByTestId(container, 'upstream-registry-header-container') + const actions3DotsBtn = pageHeader.querySelector('span[data-icon=Options') + expect(actions3DotsBtn).toBeInTheDocument() + await userEvent.click(actions3DotsBtn!) + const selectPopover = document.getElementsByClassName('bp3-popover')[0] as HTMLElement + + const items = selectPopover.getElementsByClassName('bp3-menu-item') + const actionItem = items[0] + expect(actionItem.querySelector('span[data-icon=code-delete]')).toBeInTheDocument() + expect(actionItem).toHaveTextContent('actions.delete') + + await userEvent.click(actionItem) + const deleteDialog = document.getElementsByClassName('bp3-dialog')[0] + expect(deleteDialog).toBeInTheDocument() + expect(deleteDialog).toHaveTextContent('upstreamProxyDetails.actions.delete.title') + expect(deleteDialog).toHaveTextContent('upstreamProxyDetails.actions.delete.contentText') + + const deleteBtn = deleteDialog.querySelector('button[aria-label=delete]') + expect(deleteBtn).toBeInTheDocument() + await userEvent.click(deleteBtn!) + + await waitFor(() => { + expect(deleteRegistry).toHaveBeenLastCalledWith({ registry_ref: 'undefined/npm-up-repo/+' }) + expect(mockHistoryPush).not.toHaveBeenCalledWith('/registries') + expect(showErrorToast).toHaveBeenLastCalledWith('error message') + }) + expect(deleteDialog).not.toBeInTheDocument() + }) + + test('Verify tab selection status', async () => { + const { container } = render( + + + + ) + + const tabList = container.querySelector('div[role=tablist]') + expect(tabList).toBeInTheDocument() + + const artifactsTab = tabList?.querySelector('div[data-tab-id=packages][aria-selected=true]') + expect(artifactsTab).toBeInTheDocument() + + const configurationTab = tabList?.querySelector('div[data-tab-id=configuration][aria-selected=false]') + expect(configurationTab).toBeInTheDocument() + + await userEvent.click(configurationTab!) + expect(mockHistoryPush).toHaveBeenCalledWith('/registries/abcd/configuration') + }) +}) + +describe('Verify configuration form', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should render form correctly with all data prefilled', async () => { + const { container } = render( + + + + ) + + // Artifact registry defination section + const registryDefinitionSection = getByTestId(container, 'upstream-registry-definition') + const nameField = queryByNameAttribute('identifier', registryDefinitionSection) + expect(nameField).toBeInTheDocument() + expect(nameField).toBeDisabled() + expect(nameField).toHaveAttribute( + 'value', + MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data.identifier + ) + + const descriptionField = queryByNameAttribute('description', registryDefinitionSection) + expect(descriptionField).toBeInTheDocument() + expect(descriptionField).not.toBeDisabled() + expect(descriptionField).toHaveTextContent( + MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data.description + ) + + const tags = registryDefinitionSection.querySelectorAll('div.bp3-tag-input-values .bp3-tag') + tags.forEach((each, idx) => { + expect(each).toHaveTextContent(MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data.labels[idx]) + }) + + // verify source selection + const sourceAuthSection = getByTestId(container, 'upstream-source-auth-definition') + const sourceSection = sourceAuthSection.querySelector('input[type=radio][name="config.source"][value=NpmJs]') + expect(sourceSection).toBeChecked() + expect(sourceSection).not.toBeDisabled() + + // verify auth type selection + const authTypeSelection = sourceAuthSection.querySelector( + 'input[type=radio][name="config.authType"][value=Anonymous]' + ) + expect(authTypeSelection).toBeChecked() + expect(authTypeSelection).not.toBeDisabled() + + // Security scan section + const securityScanSection = queryByTestId(container, 'security-section') + expect(securityScanSection).toBeInTheDocument() + + // artifact filtering rules + const filteringRulesSection = getByTestId(container, 'include-exclude-patterns-section') + expect(filteringRulesSection).toBeInTheDocument() + + const allowedPatternsSection = filteringRulesSection.querySelectorAll('div.bp3-form-group')[0] + const allowedPatterns = allowedPatternsSection.querySelectorAll('div.bp3-tag-input-values .bp3-tag') + allowedPatterns.forEach((each, idx) => { + expect(each).toHaveTextContent( + MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data.allowedPattern[idx] + ) + }) + + const blockedPatternsSection = filteringRulesSection.querySelectorAll('div.bp3-form-group')[1] + const blockedPatterns = blockedPatternsSection.querySelectorAll('div.bp3-tag-input-values .bp3-tag') + blockedPatterns.forEach((each, idx) => { + expect(each).toHaveTextContent( + MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data.blockedPattern[idx] + ) + }) + + const advancedOptionTitle = getByText(container, 'repositoryDetails.repositoryForm.advancedOptionsTitle') + expect(advancedOptionTitle).toBeInTheDocument() + await userEvent.click(advancedOptionTitle) + + // cleanup policy section + const cleanupPoliciesSection = getByTestId(container, 'cleanup-policy-section') + expect(cleanupPoliciesSection).toBeInTheDocument() + const addCleanupPolicyBtn = cleanupPoliciesSection.querySelector( + 'a[role=button][aria-label="cleanupPolicy.addBtn"]' + ) + expect(addCleanupPolicyBtn).toBeInTheDocument() + expect(addCleanupPolicyBtn).toHaveAttribute('disabled', '') + + // action buttons + const saveBtn = container.querySelector('button[aria-label=save]') + expect(saveBtn).toBeDisabled() + + const discardBtn = container.querySelector('button[aria-label=discard]') + expect(discardBtn).toBeDisabled() + }) + + test('should able to submit the form with updated data: Success Scenario', async () => { + const { container } = render( + + + + ) + + const descriptionField = queryByNameAttribute('description', container) + fireEvent.change(descriptionField!, { target: { value: 'updated description' } }) + expect(descriptionField).toHaveTextContent('updated description') + + const saveBtn = container.querySelector('button[aria-label=save]') + expect(saveBtn).not.toBeDisabled() + + const discardBtn = container.querySelector('button[aria-label=discard]') + expect(discardBtn).not.toBeDisabled() + + await userEvent.click(saveBtn!) + await waitFor(() => { + expect(modifyRepository).toHaveBeenLastCalledWith({ + body: { + ...MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data, + description: 'updated description' + }, + registry_ref: 'undefined/abcd/+' + }) + expect(showSuccessToast).toHaveBeenLastCalledWith( + 'upstreamProxyDetails.actions.createUpdateModal.updateSuccessMessage' + ) + }) + }) + + test('Verify source and auth section with multiple scenarios', async () => { + const { container } = render( + + + + ) + + const saveBtn = container.querySelector('button[aria-label=save]') + + const sourceAuthSection = getByTestId(container, 'upstream-source-auth-definition') + + // verify NpmJs, UserPassword + { + await upstreamProxyUtils.verifySourceAndAuthSection( + sourceAuthSection, + 'NpmJs', + 'UserPassword', + 'NpmJs', + 'Anonymous' + ) + + userEvent.click(saveBtn!) + await waitFor(() => { + expect(modifyRepository).toHaveBeenLastCalledWith({ + body: { + allowedPattern: ['test1', 'test2'], + blockedPattern: ['test3', 'test4'], + config: { + auth: { authType: 'UserPassword', secretIdentifier: 'password', userName: 'username' }, + authType: 'UserPassword', + source: 'NpmJs', + type: 'UPSTREAM', + url: '' + }, + createdAt: '1738516362995', + description: 'test description', + identifier: 'npm-up-repo', + labels: ['label1', 'label2', 'label3', 'label4'], + packageType: 'NPM', + url: '' + }, + registry_ref: 'undefined/abcd/+' + }) + }) + } + + // verify Custom, Anonymous + { + await upstreamProxyUtils.verifySourceAndAuthSection( + sourceAuthSection, + 'Custom', + 'Anonymous', + 'NpmJs', + 'Anonymous' + ) + + userEvent.click(saveBtn!) + await waitFor(() => { + expect(modifyRepository).toHaveBeenLastCalledWith({ + body: { + allowedPattern: ['test1', 'test2'], + blockedPattern: ['test3', 'test4'], + config: { + auth: null, + authType: 'Anonymous', + source: 'Custom', + type: 'UPSTREAM', + url: 'https://custom.docker.com' + }, + createdAt: '1738516362995', + description: 'test description', + identifier: 'npm-up-repo', + labels: ['label1', 'label2', 'label3', 'label4'], + packageType: 'NPM', + url: '' + }, + registry_ref: 'undefined/abcd/+' + }) + }) + } + + // verify Custom, UserPassword + { + await upstreamProxyUtils.verifySourceAndAuthSection( + sourceAuthSection, + 'Custom', + 'UserPassword', + 'NpmJs', + 'Anonymous' + ) + + userEvent.click(saveBtn!) + await waitFor(() => { + expect(modifyRepository).toHaveBeenLastCalledWith({ + body: { + allowedPattern: ['test1', 'test2'], + blockedPattern: ['test3', 'test4'], + config: { + auth: { authType: 'UserPassword', secretIdentifier: 'password', userName: 'username' }, + authType: 'UserPassword', + source: 'Custom', + type: 'UPSTREAM', + url: 'https://custom.docker.com' + }, + createdAt: '1738516362995', + description: 'test description', + identifier: 'npm-up-repo', + labels: ['label1', 'label2', 'label3', 'label4'], + packageType: 'NPM', + url: '' + }, + registry_ref: 'undefined/abcd/+' + }) + }) + } + }) + + test('should able to submit the form with updated data: Failure Scenario', async () => { + modifyRepository.mockImplementationOnce( + () => + new Promise((_, onReject) => { + onReject({ message: 'error message' }) + }) + ) + const { container } = render( + + + + ) + + const descriptionField = queryByNameAttribute('description', container) + fireEvent.change(descriptionField!, { target: { value: 'updated description' } }) + expect(descriptionField).toHaveTextContent('updated description') + + const saveBtn = container.querySelector('button[aria-label=save]') + expect(saveBtn).not.toBeDisabled() + + const discardBtn = container.querySelector('button[aria-label=discard]') + expect(discardBtn).not.toBeDisabled() + + await userEvent.click(saveBtn!) + await waitFor(() => { + expect(modifyRepository).toHaveBeenLastCalledWith({ + body: { + ...MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData.content.data, + description: 'updated description' + }, + registry_ref: 'undefined/abcd/+' + }) + expect(showErrorToast).toHaveBeenLastCalledWith('error message') + }) + }) +}) diff --git a/web/src/ar/pages/repository-details/NpmRepository/__tests__/__mockData__.ts b/web/src/ar/pages/repository-details/NpmRepository/__tests__/__mockData__.ts new file mode 100644 index 000000000..93c5c788f --- /dev/null +++ b/web/src/ar/pages/repository-details/NpmRepository/__tests__/__mockData__.ts @@ -0,0 +1,357 @@ +/* + * Copyright 2024 Harness, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { GetAllArtifactsByRegistryOkResponse } from '@harnessio/react-har-service-client' + +export const MockGetNpmRegistryResponseWithAllData = { + content: { + data: { + config: { + type: 'VIRTUAL', + upstreamProxies: ['npm-js-proxy'] + }, + createdAt: '1738040653409', + description: 'custom description', + identifier: 'npm-repo', + labels: ['label1', 'label2', 'label3', 'label4'], + modifiedAt: '1738040653409', + packageType: 'NPM', + url: 'http://host.docker.internal:3000/artifact-registry/maven-repo', + allowedPattern: ['test1', 'test2'], + blockedPattern: ['test3', 'test4'] + }, + status: 'SUCCESS' + } +} + +export const MockGetNpmArtifactsByRegistryResponse: GetAllArtifactsByRegistryOkResponse = { + content: { + data: { + artifacts: [ + { + downloadsCount: 0, + lastModified: '1738048875014', + latestVersion: 'v1', + name: 'artifact', + packageType: 'NPM', + registryIdentifier: 'npm-repo', + registryPath: '' + } + ], + itemCount: 0, + pageCount: 0, + pageIndex: 0, + pageSize: 50 + }, + status: 'SUCCESS' + } +} + +export const MockGetNpmSetupClientOnRegistryConfigPageResponse = { + content: { + data: { + mainHeader: 'Maven Client Setup', + secHeader: 'Follow these instructions to install/use Maven artifacts or compatible packages.', + sections: [ + { + header: '1. Generate Identity Token', + secHeader: 'An identity token will serve as the password for uploading and downloading artifacts.', + steps: [ + { + header: 'Generate an identity token', + type: 'GenerateToken' + } + ], + type: 'INLINE' + }, + { + tabs: [ + { + header: 'Maven', + sections: [ + { + header: '2. Pull a Maven Package', + secHeader: 'Set default repository in your pom.xml file.', + steps: [ + { + commands: [ + { + value: + '\u003crepositories\u003e\n \u003crepository\u003e\n \u003cid\u003emaven-dev\u003c/id\u003e\n \u003curl\u003ehttp://host.docker.internal:3000/maven/artifact-registry/maven-repo\u003c/url\u003e\n \u003creleases\u003e\n \u003cenabled\u003etrue\u003c/enabled\u003e\n \u003cupdatePolicy\u003ealways\u003c/updatePolicy\u003e\n \u003c/releases\u003e\n \u003csnapshots\u003e\n \u003cenabled\u003etrue\u003c/enabled\u003e\n \u003cupdatePolicy\u003ealways\u003c/updatePolicy\u003e\n \u003c/snapshots\u003e\n \u003c/repository\u003e\n\u003c/repositories\u003e' + } + ], + header: 'To set default registry in your pom.xml file by adding the following:', + type: 'Static' + }, + { + commands: [ + { + value: + '\u003csettings\u003e\n \u003cservers\u003e\n \u003cserver\u003e\n \u003cid\u003emaven-dev\u003c/id\u003e\n \u003cusername\u003eadmin@gitness.io\u003c/username\u003e\n \u003cpassword\u003eidentity-token\u003c/password\u003e\n \u003c/server\u003e\n \u003c/servers\u003e\n\u003c/settings\u003e' + } + ], + header: + 'Copy the following your ~/ .m2/settings.xml file for MacOs, or $USERPROFILE$\\ .m2\\settings.xml for Windows to authenticate with token to pull from your Maven registry.', + type: 'Static' + }, + { + commands: [ + { + value: + '\u003cdependency\u003e\n \u003cgroupId\u003e\u003cGROUP_ID\u003e\u003c/groupId\u003e\n \u003cartifactId\u003e\u003cARTIFACT_ID\u003e\u003c/artifactId\u003e\n \u003cversion\u003e\u003cVERSION\u003e\u003c/version\u003e\n\u003c/dependency\u003e' + } + ], + header: + "Add a dependency to the project's pom.xml (replace \u003cGROUP_ID\u003e, \u003cARTIFACT_ID\u003e \u0026 \u003cVERSION\u003e with your own):", + type: 'Static' + }, + { + commands: [ + { + value: 'mvn install' + } + ], + header: 'Install dependencies in pom.xml file', + type: 'Static' + } + ], + type: 'INLINE' + }, + { + header: '3. Push a Maven Package', + secHeader: 'Set default repository in your pom.xml file.', + steps: [ + { + commands: [ + { + value: + '\u003cdistributionManagement\u003e\n \u003csnapshotRepository\u003e\n \u003cid\u003emaven-dev\u003c/id\u003e\n \u003curl\u003ehttp://host.docker.internal:3000/maven/artifact-registry/maven-repo\u003c/url\u003e\n \u003c/snapshotRepository\u003e\n \u003crepository\u003e\n \u003cid\u003emaven-dev\u003c/id\u003e\n \u003curl\u003ehttp://host.docker.internal:3000/maven/artifact-registry/maven-repo\u003c/url\u003e\n \u003c/repository\u003e\n\u003c/distributionManagement\u003e' + } + ], + header: 'To set default registry in your pom.xml file by adding the following:', + type: 'Static' + }, + { + commands: [ + { + value: + '\u003csettings\u003e\n \u003cservers\u003e\n \u003cserver\u003e\n \u003cid\u003emaven-dev\u003c/id\u003e\n \u003cusername\u003eadmin@gitness.io\u003c/username\u003e\n \u003cpassword\u003eidentity-token\u003c/password\u003e\n \u003c/server\u003e\n \u003c/servers\u003e\n\u003c/settings\u003e' + } + ], + header: + 'Copy the following your ~/ .m2/setting.xml file for MacOs, or $USERPROFILE$\\ .m2\\settings.xml for Windows to authenticate with token to push to your Maven registry.', + type: 'Static' + }, + { + commands: [ + { + value: 'mvn deploy' + } + ], + header: 'Publish package to your Maven registry.', + type: 'Static' + } + ], + type: 'INLINE' + } + ] + }, + { + header: 'Gradle', + sections: [ + { + header: '2. Pull a Gradle Package', + secHeader: 'Set default repository in your build.gradle file.', + steps: [ + { + commands: [ + { + value: + 'repositories{\n maven{\n url “http://host.docker.internal:3000/maven/artifact-registry/maven-repo”\n\n credentials {\n username “admin@gitness.io”\n password “identity-token”\n }\n }\n}' + } + ], + header: 'Set the default registry in your project’s build.gradle by adding the following:', + type: 'Static' + }, + { + commands: [ + { + value: 'repositoryUser=admin@gitness.io\nrepositoryPassword={{identity-token}}' + } + ], + header: + 'As this is a private registry, you’ll need to authenticate. Create or add to the ~/.gradle/gradle.properties file with the following:', + type: 'Static' + }, + { + commands: [ + { + value: + 'dependencies {\n implementation ‘\u003cGROUP_ID\u003e:\u003cARTIFACT_ID\u003e:\u003cVERSION\u003e’\n}' + } + ], + header: 'Add a dependency to the project’s build.gradle', + type: 'Static' + }, + { + commands: [ + { + value: 'gradlew build // Linux or OSX\n gradlew.bat build // Windows' + } + ], + header: 'Install dependencies in build.gradle file', + type: 'Static' + } + ], + type: 'INLINE' + }, + { + header: '3. Push a Gradle Package', + secHeader: 'Set default repository in your build.gradle file.', + steps: [ + { + commands: [ + { + value: + "publishing {\n publications {\n maven(MavenPublication) {\n groupId = '\u003cGROUP_ID\u003e'\n artifactId = '\u003cARTIFACT_ID\u003e'\n version = '\u003cVERSION\u003e'\n\n from components.java\n }\n }\n}" + } + ], + header: 'Add a maven publish plugin configuration to the project’s build.gradle.', + type: 'Static' + }, + { + commands: [ + { + value: 'gradlew publish' + } + ], + header: 'Publish package to your Maven registry.', + type: 'Static' + } + ], + type: 'INLINE' + } + ] + }, + { + header: 'Sbt/Scala', + sections: [ + { + header: '2. Pull a Sbt/Scala Package', + secHeader: 'Set default repository in your build.sbt file.', + steps: [ + { + commands: [ + { + value: + 'resolver += “Harness Registry” at “http://host.docker.internal:3000/maven/artifact-registry/maven-repo”\ncredentials += Credentials(Path.userHome / “.sbt” / “.Credentials”)' + } + ], + header: 'Set the default registry in your project’s build.sbt by adding the following:', + type: 'Static' + }, + { + commands: [ + { + value: + 'realm=Harness Registry\nhost=host.docker.internal:3000\nuser=admin@gitness.io\npassword={{identity-token}}' + } + ], + header: + 'As this is a private registry, you’ll need to authenticate. Create or add to the ~/.sbt/.credentials file with the following:', + type: 'Static' + }, + { + commands: [ + { + value: + 'libraryDependencies += “\u003cGROUP_ID\u003e” % “\u003cARTIFACT_ID\u003e” % “\u003cVERSION\u003e”' + } + ], + header: 'Add a dependency to the project’s build.sbt', + type: 'Static' + }, + { + commands: [ + { + value: 'sbt update' + } + ], + header: 'Install dependencies in build.sbt file', + type: 'Static' + } + ], + type: 'INLINE' + }, + { + header: '3. Push a Sbt/Scala Package', + secHeader: 'Set default repository in your build.sbt file.', + steps: [ + { + commands: [ + { + value: + 'publishTo := Some("Harness Registry" at "http://host.docker.internal:3000/maven/artifact-registry/maven-repo")' + } + ], + header: 'Add publish configuration to the project’s build.sbt.', + type: 'Static' + }, + { + commands: [ + { + value: 'sbt publish' + } + ], + header: 'Publish package to your Maven registry.', + type: 'Static' + } + ], + type: 'INLINE' + } + ] + } + ], + type: 'TABS' + } + ] + }, + status: 'SUCCESS' + } +} + +export const MockGetNpmUpstreamRegistryResponseWithNpmJsSourceAllData = { + content: { + data: { + allowedPattern: ['test1', 'test2'], + blockedPattern: ['test3', 'test4'], + config: { + auth: null, + authType: 'Anonymous', + source: 'NpmJs', + type: 'UPSTREAM', + url: '' + }, + createdAt: '1738516362995', + identifier: 'npm-up-repo', + description: 'test description', + packageType: 'NPM', + labels: ['label1', 'label2', 'label3', 'label4'], + url: '' + }, + status: 'SUCCESS' + } +} diff --git a/web/src/ar/pages/repository-details/RepositoryFactory.tsx b/web/src/ar/pages/repository-details/RepositoryFactory.tsx index c3947bf4b..333798ee2 100644 --- a/web/src/ar/pages/repository-details/RepositoryFactory.tsx +++ b/web/src/ar/pages/repository-details/RepositoryFactory.tsx @@ -19,8 +19,10 @@ import { DockerRepositoryType } from './DockerRepository/DockerRepositoryType' import { MavenRepositoryType } from './MavenRepository/MavenRepository' import { HelmRepositoryType } from './HelmRepository/HelmRepositoryType' import { GenericRepositoryType } from './GenericRepository/GenericRepositoryType' +import { NpmRepositoryType } from './NpmRepository/NpmRepositoryType' repositoryFactory.registerStep(new DockerRepositoryType()) repositoryFactory.registerStep(new HelmRepositoryType()) repositoryFactory.registerStep(new GenericRepositoryType()) repositoryFactory.registerStep(new MavenRepositoryType()) +repositoryFactory.registerStep(new NpmRepositoryType()) diff --git a/web/src/ar/pages/upstream-proxy-details/components/AuthenticationFormInput/constants.tsx b/web/src/ar/pages/upstream-proxy-details/components/AuthenticationFormInput/constants.tsx index 365bc5c2d..41df40ee8 100644 --- a/web/src/ar/pages/upstream-proxy-details/components/AuthenticationFormInput/constants.tsx +++ b/web/src/ar/pages/upstream-proxy-details/components/AuthenticationFormInput/constants.tsx @@ -58,5 +58,9 @@ export const URLSourceToSupportedAuthTypesMapping: Record< [UpstreamRepositoryURLInputSource.MavenCentral]: [ UpstreamProxyAuthenticationMode.USER_NAME_AND_PASSWORD, UpstreamProxyAuthenticationMode.ANONYMOUS + ], + [UpstreamRepositoryURLInputSource.NpmJS]: [ + UpstreamProxyAuthenticationMode.USER_NAME_AND_PASSWORD, + UpstreamProxyAuthenticationMode.ANONYMOUS ] } diff --git a/web/src/ar/pages/upstream-proxy-details/components/RepositoryUrlInput/constants.ts b/web/src/ar/pages/upstream-proxy-details/components/RepositoryUrlInput/constants.ts index be5a28d5c..0362ac7a4 100644 --- a/web/src/ar/pages/upstream-proxy-details/components/RepositoryUrlInput/constants.ts +++ b/web/src/ar/pages/upstream-proxy-details/components/RepositoryUrlInput/constants.ts @@ -37,5 +37,9 @@ export const UpstreamURLSourceConfig: Record