]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20348 Improve axios + cover users endpoints with axios
authorViktor Vorona <viktor.vorona@sonarsource.com>
Fri, 15 Sep 2023 14:01:11 +0000 (16:01 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 20 Sep 2023 20:02:50 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
server/sonar-web/src/main/js/api/provisioning.ts
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
server/sonar-web/src/main/js/queries/users.ts
server/sonar-web/src/main/js/types/axios.d.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/users.ts

index d8e61963ae1a0e72576aea5d394d89865e085cb6..3c76abf76b0ab221f8cd12b973ef270efdc15bcb 100644 (file)
 
 import { isAfter, isBefore } from 'date-fns';
 import { cloneDeep, isEmpty, isUndefined, omitBy } from 'lodash';
+import { HttpStatus } from '../../helpers/request';
 import { mockClusterSysInfo, mockIdentityProvider, mockRestUser } from '../../helpers/testMocks';
 import { IdentityProvider, SysInfoCluster } from '../../types/types';
-import { ChangePasswordResults, RestUserDetailed, User } from '../../types/users';
+import { ChangePasswordResults, RestUserDetailed } from '../../types/users';
 import { getSystemInfo } from '../system';
 import { addUserToGroup, removeUserFromGroup } from '../user_groups';
 import {
@@ -130,7 +131,7 @@ export default class UsersServiceMock {
   constructor() {
     jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo);
     jest.mocked(getIdentityProviders).mockImplementation(this.handleGetIdentityProviders);
-    jest.mocked(getUsers).mockImplementation((p) => this.handleGetUsers(p));
+    jest.mocked(getUsers).mockImplementation(this.handleGetUsers);
     jest.mocked(postUser).mockImplementation(this.handlePostUser);
     jest.mocked(updateUser).mockImplementation(this.handleUpdateUser);
     jest.mocked(getUserGroups).mockImplementation(this.handleGetUserGroups);
@@ -265,8 +266,10 @@ export default class UsersServiceMock {
     const { email, local, login, name, scmAccounts } = data;
     if (scmAccounts.some((a) => isEmpty(a.trim()))) {
       return Promise.reject({
-        status: 400,
-        json: () => Promise.resolve({ message: 'Error: Empty SCM' }),
+        response: {
+          status: HttpStatus.BadRequest,
+          data: { message: 'Error: Empty SCM' },
+        },
       });
     }
     const newUser = mockRestUser({
@@ -277,24 +280,19 @@ export default class UsersServiceMock {
       scmAccounts,
     });
     this.users.push(newUser);
-    return this.reply(undefined);
+    return this.reply(newUser);
   };
 
-  handleUpdateUser = (data: {
-    email?: string;
-    login: string;
-    name: string;
-    scmAccount: string[];
-  }) => {
-    const { email, login, name, scmAccount } = data;
-    const user = this.users.find((u) => u.login === login) as User;
+  handleUpdateUser: typeof updateUser = (id, data) => {
+    const { email, name, scmAccounts } = data;
+    const user = this.users.find((u) => u.login === id);
     if (!user) {
       return Promise.reject('No such user');
     }
     Object.assign(user, {
-      ...omitBy({ name, email, scmAccounts: scmAccount }, isUndefined),
+      ...omitBy({ name, email, scmAccounts }, isUndefined),
     });
-    return this.reply({ user });
+    return this.reply(user);
   };
 
   handleGetIdentityProviders = (): Promise<{ identityProviders: IdentityProvider[] }> => {
index 756bf57dfa452e05b0a1cdbe2b17ca4c5ab1055e..b33172edd22ed664f40c29867b83f8d00970093f 100644 (file)
@@ -58,15 +58,13 @@ export function syncNowGithubProvisioning(): Promise<void> {
 
 export function fetchGithubRolesMapping() {
   return axios
-    .get<unknown, { githubPermissionsMappings: GitHubMapping[] }>(
-      '/api/v2/github-permission-mappings',
-    )
+    .get<{ githubPermissionsMappings: GitHubMapping[] }>('/api/v2/github-permission-mappings')
     .then((data) => data.githubPermissionsMappings);
 }
 
 export function updateGithubRolesMapping(
   role: string,
   data: Partial<Pick<GitHubMapping, 'permissions'>>,
-): Promise<GitHubMapping> {
-  return axios.patch(`/api/v2/github-permission-mappings/${role}`, data);
+) {
+  return axios.patch<GitHubMapping>(`/api/v2/github-permission-mappings/${role}`, data);
 }
index 754da05a7d5cb81e86a6aebe5f91ff820a58dd88..218806c32b0efcce9ddc00f106cbbdcd65fdb0b6 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import axios from 'axios';
 import { throwGlobalError } from '../helpers/error';
-import {
-  deleteJSON,
-  getJSON,
-  HttpStatus,
-  parseJSON,
-  post,
-  postJSON,
-  postJSONBody,
-} from '../helpers/request';
+import { HttpStatus, axiosToCatch, getJSON, parseJSON, post } from '../helpers/request';
 import { IdentityProvider, Paging } from '../types/types';
 import {
   ChangePasswordResults,
@@ -34,7 +27,7 @@ import {
   HomePage,
   NoticeType,
   RestUserBase,
-  User,
+  RestUserDetailed,
 } from '../types/users';
 
 export function getCurrentUser(): Promise<CurrentUser> {
@@ -92,8 +85,8 @@ export function getUsers<T extends RestUserBase>(data: {
   sonarLintLastConnectionDateTo?: string;
   pageSize?: number;
   pageIndex?: number;
-}): Promise<{ page: Paging; users: T[] }> {
-  return getJSON('/api/v2/users', data).catch(throwGlobalError);
+}) {
+  return axios.get<{ page: Paging; users: T[] }>(`/api/v2/users`, { params: data });
 }
 
 export function postUser(data: {
@@ -102,34 +95,19 @@ export function postUser(data: {
   name: string;
   password?: string;
   scmAccounts: string[];
-}): Promise<void | Response> {
-  return postJSONBody('/api/v2/users', data);
+}) {
+  return axiosToCatch.post<RestUserDetailed>('/api/v2/users', data);
 }
 
-type UpdateUserArg =
-  | {
-      email?: string;
-      login: string;
-      name?: string;
-      scmAccount: string[];
-    }
-  | { login: string; scmAccount: string[] };
-
-export function updateUser(data: UpdateUserArg): Promise<{ user: User }> {
-  return postJSON('/api/users/update', {
-    ...data,
-    scmAccount: data.scmAccount.length > 0 ? data.scmAccount : '',
-  });
+export function updateUser(
+  id: string,
+  data: Partial<Pick<RestUserDetailed, 'email' | 'name' | 'scmAccounts'>>,
+) {
+  return axiosToCatch.patch<RestUserDetailed>(`/api/v2/users/${id}`, data);
 }
 
-export function deleteUser({
-  login,
-  anonymize,
-}: {
-  login: string;
-  anonymize?: boolean;
-}): Promise<void | Response> {
-  return deleteJSON(`/api/v2/users/${login}`, { anonymize }).catch(throwGlobalError);
+export function deleteUser({ login, anonymize }: { login: string; anonymize?: boolean }) {
+  return axios.delete(`/api/v2/users/${login}`, { params: { anonymize } });
 }
 
 export function setHomePage(homepage: HomePage): Promise<void | Response> {
index 19fc0093fb079a0b6f37ee978bc5237ccab65bd4..74ed1c6839f6b83ca545b190f385ff38c4f1b75d 100644 (file)
@@ -51,6 +51,7 @@ const ui = {
   showMore: byRole('button', { name: 'show_more' }),
   aliceUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.alice.merveille' }),
   aliceUpdateButton: byRole('button', { name: 'users.manage_user.alice.merveille' }),
+  denisUpdateButton: byRole('button', { name: 'users.manage_user.denis.villeneuve' }),
   alicedDeactivateButton: byRole('button', { name: 'users.deactivate' }),
   bobUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.bob.marley' }),
   bobUpdateButton: byRole('button', { name: 'users.manage_user.bob.marley' }),
@@ -421,6 +422,50 @@ describe('in non managed mode', () => {
 
     expect(ui.dialogPasswords.query()).not.toBeInTheDocument();
   });
+
+  it('should not allow to update non-local user', async () => {
+    const user = userEvent.setup();
+    const currentUser = mockLoggedInUser({ login: 'denis.villeneuve' });
+    renderUsersApp([], currentUser);
+
+    await act(async () => user.click(await ui.denisUpdateButton.find()));
+    await user.click(
+      await within(ui.denisRow.get()).findByRole('button', { name: 'update_details' }),
+    );
+    expect(await ui.dialogUpdateUser.find()).toBeInTheDocument();
+
+    expect(ui.userNameInput.get()).toHaveValue('Denis Villeneuve');
+    expect(ui.userNameInput.get()).toBeDisabled();
+    expect(ui.emailInput.get()).toBeDisabled();
+    await user.click(ui.scmAddButton.get());
+    await user.type(ui.dialogSCMInput().get(), 'SCM');
+    await act(() => user.click(ui.updateButton.get()));
+    expect(ui.dialogUpdateUser.query()).not.toBeInTheDocument();
+    expect(await within(ui.denisRow.get()).findByText('SCM')).toBeInTheDocument();
+  });
+
+  it('should be able to remove email', async () => {
+    const user = userEvent.setup();
+    const currentUser = mockLoggedInUser({ login: 'alice.merveille' });
+    renderUsersApp([], currentUser);
+
+    expect(
+      within(await ui.aliceRow.find()).getByText('alice.merveille@wonderland.com'),
+    ).toBeInTheDocument();
+    await act(async () => user.click(await ui.aliceUpdateButton.find()));
+    await user.click(
+      await within(ui.aliceRow.get()).findByRole('button', { name: 'update_details' }),
+    );
+    expect(await ui.dialogUpdateUser.find()).toBeInTheDocument();
+
+    expect(ui.emailInput.get()).toHaveValue('alice.merveille@wonderland.com');
+    await user.clear(ui.emailInput.get());
+    await act(() => user.click(ui.updateButton.get()));
+    expect(ui.dialogUpdateUser.query()).not.toBeInTheDocument();
+    expect(
+      within(ui.aliceRow.get()).queryByText('alice.merveille@wonderland.com'),
+    ).not.toBeInTheDocument();
+  });
 });
 
 describe('in manage mode', () => {
index 9a74b5ae74596370a1e5d00c39502506af0bed21..c722d017cfcd97c344acfaa5f4a18d8e0a669a5e 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { AxiosError, AxiosResponse } from 'axios';
 import * as React from 'react';
 import SimpleModal from '../../../components/controls/SimpleModal';
 import { Button, ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import { Alert } from '../../../components/ui/Alert';
 import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
 import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
-import { throwGlobalError } from '../../../helpers/error';
+import { addGlobalErrorMessage } from '../../../helpers/globalMessages';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { parseError } from '../../../helpers/request';
+import { parseErrorResponse } from '../../../helpers/request';
 import { usePostUserMutation, useUpdateUserMutation } from '../../../queries/users';
 import { RestUserDetailed } from '../../../types/users';
 import UserScmAccountInput from './UserScmAccountInput';
@@ -52,11 +53,14 @@ export default function UserForm(props: Props) {
   const [scmAccounts, setScmAccounts] = React.useState<string[]>(user?.scmAccounts ?? []);
   const [error, setError] = React.useState<string | undefined>(undefined);
 
-  const handleError = (response: Response) => {
-    if (![BAD_REQUEST, INTERNAL_SERVER_ERROR].includes(response.status)) {
-      throwGlobalError(response);
+  const handleError = (error: AxiosError<AxiosResponse>) => {
+    const { response } = error;
+    const message = parseErrorResponse(response);
+
+    if (!response || ![BAD_REQUEST, INTERNAL_SERVER_ERROR].includes(response.status)) {
+      addGlobalErrorMessage(message);
     } else {
-      parseError(response).then((errorMsg) => setError(errorMsg), throwGlobalError);
+      setError(message);
     }
   };
 
@@ -77,14 +81,17 @@ export default function UserForm(props: Props) {
     const { user } = props;
 
     updateUser(
-      isInstanceManaged
-        ? { scmAccount: scmAccounts, login }
-        : {
-            email: user?.local ? email : undefined,
-            login,
-            name: user?.local ? name : undefined,
-            scmAccount: scmAccounts,
-          },
+      {
+        id: login,
+        data:
+          isInstanceManaged || !user?.local
+            ? { scmAccounts }
+            : {
+                email: email !== '' ? email : null,
+                name,
+                scmAccounts,
+              },
+      },
       { onSuccess: props.onClose, onError: handleError },
     );
   };
index 3826de75f26ffa0d80748ae4356faac907d67c98..45ddf02c39be399b2877a15e4fc442101e933bf4 100644 (file)
@@ -94,7 +94,13 @@ export function useUpdateUserMutation() {
   const queryClient = useQueryClient();
 
   return useMutation({
-    mutationFn: (data: Parameters<typeof updateUser>[0]) => updateUser(data),
+    mutationFn: ({
+      id,
+      data,
+    }: {
+      id: Parameters<typeof updateUser>[0];
+      data: Parameters<typeof updateUser>[1];
+    }) => updateUser(id, data),
     onSuccess() {
       queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
     },
diff --git a/server/sonar-web/src/main/js/types/axios.d.ts b/server/sonar-web/src/main/js/types/axios.d.ts
new file mode 100644 (file)
index 0000000..c9b67a2
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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 'axios';
+
+declare module 'axios' {
+  export interface AxiosInstance {
+    get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>;
+    delete<T = void>(url: string, config?: AxiosRequestConfig): Promise<T>;
+    post<T = any, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<T>;
+    patch<T = any, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<T>;
+
+    defaults: Omit<AxiosDefaults, 'headers'> & {
+      headers: HeadersDefaults & {
+        [key: string]: AxiosHeaderValue;
+      };
+    };
+  }
+}
index a556d6c1a7c529fc9a11c142c983d3c9300fd937..9ee0ffdf38922e2693cc3fae2bfba01668c5cfc8 100644 (file)
@@ -87,7 +87,7 @@ export interface User extends UserBase {
 export interface UserBase {
   active?: boolean;
   avatar?: string;
-  email?: string;
+  email?: string | null;
   login: string;
   name?: string;
 }
@@ -99,7 +99,7 @@ export interface RestUserBase {
 }
 
 export interface RestUser extends RestUserBase {
-  email: string;
+  email: string | null;
   active: boolean;
   local: boolean;
   externalProvider: string;