From 91090d511b75c9336e9102edffd7e9d7dd36a090 Mon Sep 17 00:00:00 2001 From: Viktor Vorona Date: Wed, 5 Jul 2023 11:55:01 +0200 Subject: [PATCH] SONAR-19784 Disable visibility change for GH projects with auto-provisioning --- .../components/PermissionsProjectApp.tsx | 25 +++-- .../__tests__/PermissionsProject-it.tsx | 105 +++++++++++++++--- .../ChangeDefaultVisibilityForm.tsx | 105 +++++++++--------- .../__tests__/ProjectManagementApp-it.tsx | 21 +++- .../queries/identity-provider.ts | 5 +- .../components/common/VisibilitySelector.tsx | 5 +- .../src/main/js/helpers/UseQuery.tsx | 35 ++++++ .../src/main/js/helpers/react-query.ts | 34 ++++++ .../src/main/js/queries/github-sync.ts | 8 +- .../resources/org/sonar/l10n/core.properties | 1 + 10 files changed, 258 insertions(+), 86 deletions(-) create mode 100644 server/sonar-web/src/main/js/helpers/UseQuery.tsx create mode 100644 server/sonar-web/src/main/js/helpers/react-query.ts diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx index fa2da7b9afb..28bbffea24a 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx @@ -25,14 +25,17 @@ import withComponentContext from '../../../../app/components/componentContext/wi import VisibilitySelector from '../../../../components/common/VisibilitySelector'; import AllHoldersList from '../../../../components/permissions/AllHoldersList'; import { FilterOption } from '../../../../components/permissions/SearchForm'; +import UseQuery from '../../../../helpers/UseQuery'; import { translate } from '../../../../helpers/l10n'; import { PERMISSIONS_ORDER_BY_QUALIFIER, convertToPermissionDefinitions, } from '../../../../helpers/permissions'; +import { AlmKeys } from '../../../../types/alm-settings'; import { ComponentContextShape, Visibility } from '../../../../types/component'; import { Permissions } from '../../../../types/permissions'; import { Component, Paging, PermissionGroup, PermissionUser } from '../../../../types/types'; +import { useGithubStatusQuery } from '../../../settings/components/authentication/queries/identity-provider'; import '../../styles.css'; import PageHeader from './PageHeader'; import PublicProjectDisclaimer from './PublicProjectDisclaimer'; @@ -319,7 +322,7 @@ class PermissionsProjectApp extends React.PureComponent { }; render() { - const { component } = this.props; + const { component, projectBinding } = this.props; const { filter, groups, @@ -346,13 +349,19 @@ class PermissionsProjectApp extends React.PureComponent {
- + + {({ data: githubProvisioningStatus, isFetching }) => ( + + )} + + {disclaimer && ( { serviceMock = new PermissionsServiceMock(); + authHandler = new AuthenticationServiceMock(); }); afterEach(() => { serviceMock.reset(); + authHandler.reset(); }); describe('rendering', () => { @@ -220,20 +233,80 @@ it('should correctly handle pagination', async () => { expect(screen.getAllByRole('row').length).toBe(21); }); -function renderPermissionsProjectApp(override?: Partial) { - return renderAppWithComponentContext( - 'project_roles', - projectPermissionsRoutes, +it('should not allow to change visibility for GH Project with auto-provisioning', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = true; + renderPermissionsProjectApp( + {}, + { featureList: [Feature.GithubProvisioning] }, + { projectBinding: { alm: AlmKeys.GitHub, key: 'test', repository: 'test', monorepo: false } } + ); + await ui.appLoaded(); + + expect(ui.visibilityRadio(Visibility.Public).get()).toHaveClass('disabled'); + expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); + expect(ui.visibilityRadio(Visibility.Private).get()).toHaveClass('disabled'); + await act(async () => { + await ui.turnProjectPrivate(); + }); + expect(ui.visibilityRadio(Visibility.Private).get()).not.toBeChecked(); +}); + +it('should allow to change visibility for non-GH Project', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = true; + renderPermissionsProjectApp( + {}, + { featureList: [Feature.GithubProvisioning] }, + { projectBinding: { alm: AlmKeys.Azure, key: 'test', repository: 'test', monorepo: false } } + ); + await ui.appLoaded(); + + expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled'); + expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); + expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled'); + await act(async () => { + await ui.turnProjectPrivate(); + }); + expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked(); +}); + +it('should allow to change visibility for GH Project with disabled auto-provisioning', async () => { + const user = userEvent.setup(); + const ui = getPageObject(user); + authHandler.githubProvisioningStatus = false; + renderPermissionsProjectApp( {}, - { - component: mockComponent({ - visibility: Visibility.Public, - configuration: { - canUpdateProjectVisibilityToPrivate: true, - canApplyPermissionTemplate: true, - }, - ...override, - }), - } + { featureList: [Feature.GithubProvisioning] }, + { projectBinding: { alm: AlmKeys.GitHub, key: 'test', repository: 'test', monorepo: false } } ); + await ui.appLoaded(); + + expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled'); + expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked(); + expect(ui.visibilityRadio(Visibility.Private).get()).not.toHaveClass('disabled'); + await act(async () => { + await ui.turnProjectPrivate(); + }); + expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked(); +}); + +function renderPermissionsProjectApp( + override: Partial = {}, + contextOverride: Partial = {}, + componentContextOverride: Partial = {} +) { + return renderAppWithComponentContext('project_roles', projectPermissionsRoutes, contextOverride, { + component: mockComponent({ + visibility: Visibility.Public, + configuration: { + canUpdateProjectVisibilityToPrivate: true, + canApplyPermissionTemplate: true, + }, + ...override, + }), + ...componentContextOverride, + }); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx index 242ef8f106f..0a6c7791a2d 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx @@ -17,13 +17,14 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import * as React from 'react'; +import React, { useState } from 'react'; import Modal from '../../components/controls/Modal'; import Radio from '../../components/controls/Radio'; import { Button, ResetButtonLink } from '../../components/controls/buttons'; import { Alert } from '../../components/ui/Alert'; import { translate } from '../../helpers/l10n'; import { Visibility } from '../../types/component'; +import { useGithubStatusQuery } from '../settings/components/authentication/queries/identity-provider'; export interface Props { defaultVisibility: Visibility; @@ -31,66 +32,62 @@ export interface Props { onConfirm: (visiblity: Visibility) => void; } -interface State { - visibility: Visibility; -} - -export default class ChangeDefaultVisibilityForm extends React.PureComponent { - constructor(props: Props) { - super(props); - this.state = { visibility: props.defaultVisibility }; - } +export default function ChangeDefaultVisibilityForm(props: Props) { + const [visibility, setVisibility] = useState(props.defaultVisibility); + const { data: githubProbivisioningEnabled } = useGithubStatusQuery(); - handleConfirmClick = () => { - this.props.onConfirm(this.state.visibility); - this.props.onClose(); + const handleConfirmClick = () => { + props.onConfirm(visibility); + props.onClose(); }; - handleVisibilityChange = (visibility: Visibility) => { - this.setState({ visibility }); + const handleVisibilityChange = (visibility: Visibility) => { + setVisibility(visibility); }; - render() { - const header = translate('settings.projects.change_visibility_form.header'); + const header = translate('settings.projects.change_visibility_form.header'); - return ( - -
-

{header}

-
+ return ( + +
+

{header}

+
-
- {Object.values(Visibility).map((visibility) => ( -
- -
- {translate('visibility', visibility)} -

- {translate('visibility', visibility, 'description.short')} -

-
-
-
- ))} +
+ {Object.values(Visibility).map((visibilityValue) => ( +
+ +
+ {translate('visibility', visibilityValue)} +

+ {translate('visibility', visibilityValue, 'description.short')} +

+
+
+
+ ))} - - {translate('settings.projects.change_visibility_form.warning')} - -
+ + {translate( + `settings.projects.change_visibility_form.warning${ + githubProbivisioningEnabled ? '.github' : '' + }` + )} + +
-
- - - {translate('cancel')} - -
-
- ); - } +
+ + + {translate('cancel')} + +
+
+ ); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx index ef615cd608d..195c37e350d 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx @@ -20,16 +20,18 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import selectEvent from 'react-select-event'; +import AuthenticationServiceMock from '../../../api/mocks/AuthenticationServiceMock'; import PermissionsServiceMock from '../../../api/mocks/PermissionsServiceMock'; import ProjectManagementServiceMock from '../../../api/mocks/ProjectsManagementServiceMock'; import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; import { mockComponent } from '../../../helpers/mocks/component'; import { mockProject } from '../../../helpers/mocks/projects'; import { mockAppState, mockCurrentUser } from '../../../helpers/testMocks'; -import { renderAppWithAdminContext } from '../../../helpers/testReactTestingUtils'; +import { RenderContext, renderAppWithAdminContext } from '../../../helpers/testReactTestingUtils'; import { byPlaceholderText, byRole, byText } from '../../../helpers/testSelector'; import { AppState } from '../../../types/appstate'; import { ComponentQualifier } from '../../../types/component'; +import { Feature } from '../../../types/features'; import { Permissions } from '../../../types/permissions'; import { GlobalSettingKeys } from '../../../types/settings'; import { LoggedInUser } from '../../../types/users'; @@ -39,6 +41,7 @@ let login: string; const permissionsHandler = new PermissionsServiceMock(); const settingsHandler = new SettingsServiceMock(); +const authHandler = new AuthenticationServiceMock(); const handler = new ProjectManagementServiceMock(settingsHandler); jest.mock('../../../api/navigation', () => ({ @@ -137,6 +140,7 @@ const ui = { visibilityPublicRadio: byRole('radio', { name: 'visibility.public visibility.public.description.short', }), + defaultVisibilityWarning: byText(/settings.projects.change_visibility_form.warning/), submitDefaultVisibilityChange: byRole('button', { name: 'settings.projects.change_visibility_form.submit', }), @@ -159,6 +163,7 @@ afterEach(() => { permissionsHandler.reset(); settingsHandler.reset(); + authHandler.reset(); handler.reset(); }); @@ -342,6 +347,7 @@ it('should create project', async () => { expect(ui.defaultVisibility.get()).toHaveTextContent('—'); await user.click(ui.editDefaultVisibility.get()); expect(await ui.changeDefaultVisibilityDialog.find()).toBeInTheDocument(); + expect(ui.defaultVisibilityWarning.get()).not.toHaveTextContent('.github'); await user.click(ui.visibilityPublicRadio.get(ui.changeDefaultVisibilityDialog.get())); await user.click(ui.submitDefaultVisibilityChange.get(ui.changeDefaultVisibilityDialog.get())); expect(ui.changeDefaultVisibilityDialog.query()).not.toBeInTheDocument(); @@ -406,9 +412,19 @@ it('should restore access to admin', async () => { expect(ui.editPermissions.get()).toBeInTheDocument(); }); +it('should show github warning on changing default visibility to admin', async () => { + const user = userEvent.setup(); + authHandler.githubProvisioningStatus = true; + renderProjectManagementApp({}, {}, { featureList: [Feature.GithubProvisioning] }); + await user.click(ui.editDefaultVisibility.get()); + expect(await ui.changeDefaultVisibilityDialog.find()).toBeInTheDocument(); + expect(ui.defaultVisibilityWarning.get()).toHaveTextContent('.github'); +}); + function renderProjectManagementApp( overrides: Partial = {}, - user: Partial = {} + user: Partial = {}, + context: Partial = {} ) { login = user?.login ?? 'gooduser1'; renderAppWithAdminContext('admin/projects_management', routes, { @@ -421,5 +437,6 @@ function renderProjectManagementApp( ...overrides, }), currentUser: mockCurrentUser(user), + ...context, }); } diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts b/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts index a5c3bf339ad..b42d4bd4f50 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/queries/identity-provider.ts @@ -30,6 +30,7 @@ import { } from '../../../../../api/provisioning'; import { getSystemInfo } from '../../../../../api/system'; import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext'; +import { mapReactQueryResult } from '../../../../../helpers/react-query'; import { useSyncStatusQuery } from '../../../../../queries/github-sync'; import { Feature } from '../../../../../types/features'; import { SysInfoCluster } from '../../../../../types/types'; @@ -53,9 +54,9 @@ export function useScimStatusQuery() { } export function useGithubStatusQuery() { - const res = useSyncStatusQuery(); + const res = useSyncStatusQuery({ noRefetch: true }); - return { ...res, data: res.data?.enabled }; + return mapReactQueryResult(res, (data) => data.enabled); } export function useToggleScimMutation() { diff --git a/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx b/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx index 1cf6318108d..25db2168c50 100644 --- a/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx +++ b/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx @@ -29,11 +29,12 @@ export interface VisibilitySelectorProps { onChange: (visibility: Visibility) => void; showDetails?: boolean; visibility?: Visibility; + disabled?: boolean; loading?: boolean; } export default function VisibilitySelector(props: VisibilitySelectorProps) { - const { className, canTurnToPrivate, visibility, showDetails, loading = false } = props; + const { className, canTurnToPrivate, visibility, showDetails, disabled, loading = false } = props; return (
{Object.values(Visibility).map((v) => ( @@ -43,7 +44,7 @@ export default function VisibilitySelector(props: VisibilitySelectorProps) { value={v} checked={v === visibility} onCheck={props.onChange} - disabled={(v === Visibility.Private && !canTurnToPrivate) || loading} + disabled={disabled || (v === Visibility.Private && !canTurnToPrivate) || loading} >
{translate('visibility', v)} diff --git a/server/sonar-web/src/main/js/helpers/UseQuery.tsx b/server/sonar-web/src/main/js/helpers/UseQuery.tsx new file mode 100644 index 00000000000..ea75cd9422d --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/UseQuery.tsx @@ -0,0 +1,35 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { UseQueryResult } from '@tanstack/react-query'; +import { ReactElement } from 'react'; + +type QueryHook = (...args: TArgs) => UseQueryResult; + +interface Props { + query: QueryHook; + args?: TArgs; + children: (value: UseQueryResult) => ReactElement; +} + +export default function UseQuery(props: Props) { + const { query, args = [] as unknown as TArgs } = props; + + return props.children(query(...args)); +} diff --git a/server/sonar-web/src/main/js/helpers/react-query.ts b/server/sonar-web/src/main/js/helpers/react-query.ts new file mode 100644 index 00000000000..c66c950602d --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/react-query.ts @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { UseQueryResult } from '@tanstack/react-query'; + +const notUndefined = (x: T | undefined): x is T => x !== undefined; + +export const mapReactQueryResult = ( + res: UseQueryResult, + mapper: (data: T) => R +): UseQueryResult => { + return { + ...res, + refetch: (...args: Parameters) => + res.refetch(...args).then((val) => mapReactQueryResult(val, mapper)), + data: notUndefined(res.data) ? mapper(res.data) : res.data, + } as UseQueryResult; +}; diff --git a/server/sonar-web/src/main/js/queries/github-sync.ts b/server/sonar-web/src/main/js/queries/github-sync.ts index 67f7e3c8edf..018e0685d86 100644 --- a/server/sonar-web/src/main/js/queries/github-sync.ts +++ b/server/sonar-web/src/main/js/queries/github-sync.ts @@ -23,13 +23,17 @@ import { fetchGithubProvisioningStatus, syncNowGithubProvisioning } from '../api import { AvailableFeaturesContext } from '../app/components/available-features/AvailableFeaturesContext'; import { Feature } from '../types/features'; -export function useSyncStatusQuery() { +interface GithubSyncStatusOptions { + noRefetch?: boolean; +} + +export function useSyncStatusQuery(options: GithubSyncStatusOptions = {}) { const hasGithubProvisioning = useContext(AvailableFeaturesContext).includes( Feature.GithubProvisioning ); return useQuery(['github_sync', 'status'], fetchGithubProvisioningStatus, { enabled: hasGithubProvisioning, - refetchInterval: 10_000, + refetchInterval: options.noRefetch ? undefined : 10_000, }); } diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 3d9df0392af..ec444bc3698 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1269,6 +1269,7 @@ settings.projects.default_visibility_of_new_projects=Default visibility of new p settings.projects.change_visibility_form.label=Change default visibility of new projects settings.projects.change_visibility_form.header=Set Default Visibility of New Projects settings.projects.change_visibility_form.warning=This will not change the visibility of already existing projects. +settings.projects.change_visibility_form.warning.github=This will not change the visibility of already existing projects. Additionally, projects bound to GitHub will not be affected by this option and will be ignored. settings.projects.change_visibility_form.submit=Change Default Visibility settings.almintegration.title=DevOps Platform Integrations -- 2.39.5