]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12245 Fix throwGlobalError and checkStatus
authorJeremy Davis <jeremy.davis@sonarsource.com>
Tue, 16 Jul 2019 14:37:21 +0000 (16:37 +0200)
committerSonarTech <sonartech@sonarsource.com>
Mon, 29 Jul 2019 18:21:10 +0000 (20:21 +0200)
18 files changed:
server/sonar-vsts/package.json
server/sonar-vsts/yarn.lock
server/sonar-web/package.json
server/sonar-web/src/main/js/api/marketplace.ts
server/sonar-web/src/main/js/api/quality-profiles.ts
server/sonar-web/src/main/js/api/rules.ts
server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts
server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/utils/__tests__/throwGlobalError-test.ts
server/sonar-web/src/main/js/app/utils/throwGlobalError.ts
server/sonar-web/src/main/js/apps/settings/components/EmailForm.tsx
server/sonar-web/src/main/js/apps/settings/store/actions.ts
server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/PasswordForm-test.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx
server/sonar-web/yarn.lock

index a8e245a716f7d01244cf9365c9d49a81bbc63aec..338563630cc28bfad573f32fcef93c707945acaa 100644 (file)
@@ -12,7 +12,7 @@
     "react": "16.8.6",
     "react-dom": "16.8.6",
     "regenerator-runtime": "0.13.2",
-    "sonar-ui-common": "0.0.14",
+    "sonar-ui-common": "0.0.18",
     "whatwg-fetch": "2.0.4"
   },
   "devDependencies": {
index c965f96ea742fc6a447fe44c60e1208def14b077..6ee2e3a7b89b78dbf3a083ab2d2097b93eda48c8 100644 (file)
@@ -5849,6 +5849,11 @@ lodash@4.17.11, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.3
   resolved "https://repox.jfrog.io/repox/api/npm/npm/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
   integrity sha1-s56mIp72B+zYniyN8SU2iRysm40=
 
+lodash@4.17.14:
+  version "4.17.14"
+  resolved "https://repox.jfrog.io/repox/api/npm/npm/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
+  integrity sha1-nOSHrmbJYlT+ILWZ8htoFgKAeLo=
+
 loglevel@^1.4.1:
   version "1.6.3"
   resolved "https://repox.jfrog.io/repox/api/npm/npm/loglevel/-/loglevel-1.6.3.tgz#77f2eb64be55a404c9fd04ad16d57c1d6d6b1280"
@@ -7358,7 +7363,7 @@ prop-types-exact@^1.2.0:
     object.assign "^4.1.0"
     reflect.ownkeys "^0.2.0"
 
-prop-types@^15.5.10, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
+prop-types@15.7.2, prop-types@^15.5.10, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
   version "15.7.2"
   resolved "https://repox.jfrog.io/repox/api/npm/npm/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
   integrity sha1-UsQedbjIfnK52TYOAga5ncv/psU=
@@ -8416,10 +8421,10 @@ sockjs@0.3.19:
     faye-websocket "^0.10.0"
     uuid "^3.0.1"
 
-sonar-ui-common@0.0.14:
-  version "0.0.14"
-  resolved "https://repox.jfrog.io/repox/api/npm/npm/sonar-ui-common/-/sonar-ui-common-0.0.14.tgz#56faa2ba62503c206e9894f55f36bd9ff4934257"
-  integrity sha1-VvqiumJQPCBumJT1Xza9n/STQlc=
+sonar-ui-common@0.0.18:
+  version "0.0.18"
+  resolved "https://repox.jfrog.io/repox/api/npm/npm/sonar-ui-common/-/sonar-ui-common-0.0.18.tgz#93b71859f83b85cc23e8c201bb9ed0420fcbb479"
+  integrity sha1-k7cYWfg7hcwj6MIBu57QQg/LtHk=
   dependencies:
     "@types/react-select" "1.2.6"
     classnames "2.2.6"
@@ -8433,7 +8438,8 @@ sonar-ui-common@0.0.14:
     date-fns "1.30.1"
     formik "1.2.0"
     history "3.3.0"
-    lodash "4.17.11"
+    lodash "4.17.14"
+    prop-types "15.7.2"
     react-draggable "3.2.1"
     react-intl "2.8.0"
     react-modal "3.8.2"
index 6ca51eb76a8328edd7c0d0215e0d9bce60df227a..7d31341e47fe89b25b1e38b7a6794eadfccb9b1a 100644 (file)
@@ -35,7 +35,7 @@
     "regenerator-runtime": "0.13.2",
     "remark-custom-blocks": "2.3.0",
     "remark-slug": "5.1.0",
-    "sonar-ui-common": "0.0.14",
+    "sonar-ui-common": "0.0.18",
     "unist-util-visit": "1.4.0",
     "valid-url": "1.0.9",
     "whatwg-fetch": "2.0.4"
index c0949f956937270957595740cc8e5a50f177c777..0b309fa61b926e0be2887efb2df1bd6047b070f3 100644 (file)
@@ -42,10 +42,10 @@ export function isValidLicense(): Promise<{ isValidLicense: boolean }> {
 }
 
 export function showLicense(): Promise<License> {
-  return getJSON('/api/editions/show_license').catch((e: { response: Response }) => {
-    if (e.response && e.response.status === 404) {
+  return getJSON('/api/editions/show_license').catch((response: Response) => {
+    if (response && response.status === 404) {
       return Promise.resolve(undefined);
     }
-    return throwGlobalError(e);
+    return throwGlobalError(response);
   });
 }
index ce6ae007fab1e06649d9fcee501cfbc727df90c5..b1a5459d4fce699b29848833c947092918c91fc4 100644 (file)
  */
 import { map } from 'lodash';
 import { csvEscape } from 'sonar-ui-common/helpers/csv';
-import {
-  checkStatus,
-  getJSON,
-  parseJSON,
-  post,
-  postJSON,
-  request,
-  RequestData
-} from 'sonar-ui-common/helpers/request';
+import { getJSON, post, postJSON, RequestData } from 'sonar-ui-common/helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
 
 export interface ProfileActions {
@@ -89,23 +81,11 @@ export function getQualityProfile(data: {
 }
 
 export function createQualityProfile(data: RequestData): Promise<any> {
-  return request('/api/qualityprofiles/create')
-    .setMethod('post')
-    .setData(data)
-    .submit()
-    .then(checkStatus)
-    .then(parseJSON)
-    .catch(throwGlobalError);
+  return postJSON('/api/qualityprofiles/create', data).catch(throwGlobalError);
 }
 
 export function restoreQualityProfile(data: RequestData): Promise<any> {
-  return request('/api/qualityprofiles/restore')
-    .setMethod('post')
-    .setData(data)
-    .submit()
-    .then(checkStatus)
-    .then(parseJSON)
-    .catch(throwGlobalError);
+  return postJSON('/api/qualityprofiles/restore', data).catch(throwGlobalError);
 }
 
 export interface ProfileProject {
@@ -125,7 +105,7 @@ export function getProfileInheritance(profileKey: string): Promise<any> {
   return getJSON('/api/qualityprofiles/inheritance', { profileKey }).catch(throwGlobalError);
 }
 
-export function setDefaultProfile(profileKey: string): Promise<void> {
+export function setDefaultProfile(profileKey: string) {
   return post('/api/qualityprofiles/set_default', { profileKey });
 }
 
@@ -142,9 +122,10 @@ export function deleteProfile(profileKey: string) {
 }
 
 export function changeProfileParent(profileKey: string, parentKey: string) {
-  return post('/api/qualityprofiles/change_parent', { profileKey, parentKey }).catch(
-    throwGlobalError
-  );
+  return post('/api/qualityprofiles/change_parent', {
+    profileKey,
+    parentKey
+  }).catch(throwGlobalError);
 }
 
 export function getImporters(): Promise<
index 7311da171a2bb74f668208a6bc45d68d004732b0..954dac86fcf03f17b24e9caa8231072b7db127ba 100644 (file)
@@ -82,13 +82,13 @@ export function createRule(data: {
 }): Promise<T.RuleDetails> {
   return postJSON('/api/rules/create', data).then(
     r => r.rule,
-    error => {
+    response => {
       // do not show global error if the status code is 409
       // this case should be handled inside a component
-      if (error && error.response && error.response.status === 409) {
-        return Promise.reject(error.response);
+      if (response && response.status === 409) {
+        return Promise.reject(response);
       } else {
-        return throwGlobalError(error);
+        return throwGlobalError(response);
       }
     }
   );
index b4901d5a201658f219813a98d129181e71ff21de..680a7f3cfb0cb50d8e3b35af4187906a97f9a1c9 100644 (file)
@@ -64,7 +64,6 @@ import DuplicationsRating from 'sonar-ui-common/components/ui/DuplicationsRating
 import Level from 'sonar-ui-common/components/ui/Level';
 import Rating from 'sonar-ui-common/components/ui/Rating';
 import { formatMeasure } from 'sonar-ui-common/helpers/measures';
-import * as request from 'sonar-ui-common/helpers/request';
 import NotFound from '../../../app/components/NotFound';
 import Favorite from '../../../components/controls/Favorite';
 import HomePageSelect from '../../../components/controls/HomePageSelect';
@@ -92,6 +91,7 @@ import addGlobalSuccessMessage from '../../utils/addGlobalSuccessMessage';
 import throwGlobalError from '../../utils/throwGlobalError';
 import A11ySkipTarget from '../a11y/A11ySkipTarget';
 import Suggestions from '../embed-docs-modal/Suggestions';
+import request from './legacy/request-legacy';
 
 const exposeLibraries = () => {
   const global = window as any;
diff --git a/server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts b/server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts
new file mode 100644 (file)
index 0000000..cb6fb2a
--- /dev/null
@@ -0,0 +1,298 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 handleRequiredAuthentication from 'sonar-ui-common/helpers/handleRequiredAuthentication';
+import request from '../request-legacy';
+
+const { checkStatus, parseError, requestTryAndRepeatUntil } = request;
+
+jest.mock('sonar-ui-common/helpers/handleRequiredAuthentication', () => ({ default: jest.fn() }));
+jest.mock('sonar-ui-common/helpers/cookies', () => ({
+  getCookie: jest.fn().mockReturnValue('qwerasdf')
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+describe('parseError', () => {
+  it('should parse error and return the message', () => {
+    return expect(
+      parseError({
+        response: { json: jest.fn().mockResolvedValue({ errors: [{ msg: 'Error1' }] }) } as any
+      })
+    ).resolves.toBe('Error1');
+  });
+
+  it('should parse error and return concatenated messages', () => {
+    return expect(
+      parseError({
+        response: {
+          json: jest.fn().mockResolvedValue({ errors: [{ msg: 'Error1' }, { msg: 'Error2' }] })
+        } as any
+      })
+    ).resolves.toBe('Error1. Error2');
+  });
+
+  it('should parse error and return default message', () => {
+    return expect(
+      parseError({
+        response: {
+          json: jest.fn().mockResolvedValue({})
+        } as any
+      })
+    ).resolves.toBe('default_error_message');
+  });
+
+  it('should parse error and return default message', () => {
+    return expect(
+      parseError({
+        response: {
+          json: jest.fn().mockRejectedValue(undefined)
+        } as any
+      })
+    ).resolves.toBe('default_error_message');
+  });
+});
+
+describe('requestTryAndRepeatUntil', () => {
+  jest.useFakeTimers();
+
+  beforeEach(() => {
+    jest.clearAllTimers();
+  });
+
+  it('should repeat call until stop condition is met', async () => {
+    const apiCall = jest.fn().mockResolvedValue({ repeat: true });
+    const stopRepeat = jest.fn().mockImplementation(({ repeat }) => !repeat);
+
+    const promiseResult = requestTryAndRepeatUntil(
+      apiCall,
+      { max: -1, slowThreshold: -20 },
+      stopRepeat
+    );
+
+    for (let i = 1; i < 5; i++) {
+      jest.runAllTimers();
+      expect(apiCall).toBeCalledTimes(i);
+      await new Promise(setImmediate);
+      expect(stopRepeat).toBeCalledTimes(i);
+    }
+    apiCall.mockResolvedValue({ repeat: false });
+    jest.runAllTimers();
+    expect(apiCall).toBeCalledTimes(5);
+    await new Promise(setImmediate);
+    expect(stopRepeat).toBeCalledTimes(5);
+
+    return expect(promiseResult).resolves.toEqual({ repeat: false });
+  });
+
+  it('should repeat call as long as there is an error', async () => {
+    const apiCall = jest.fn().mockRejectedValue({ response: { status: 504 } });
+    const stopRepeat = jest.fn().mockReturnValue(true);
+    const promiseResult = requestTryAndRepeatUntil(
+      apiCall,
+      { max: -1, slowThreshold: -20 },
+      stopRepeat
+    );
+
+    for (let i = 1; i < 5; i++) {
+      jest.runAllTimers();
+      expect(apiCall).toBeCalledTimes(i);
+      await new Promise(setImmediate);
+    }
+    apiCall.mockResolvedValue('Success');
+    jest.runAllTimers();
+    expect(apiCall).toBeCalledTimes(5);
+    await new Promise(setImmediate);
+    expect(stopRepeat).toBeCalledTimes(1);
+
+    return expect(promiseResult).resolves.toBe('Success');
+  });
+
+  it('should stop after 3 calls', async () => {
+    const apiCall = jest.fn().mockResolvedValue({});
+    const stopRepeat = jest.fn().mockReturnValue(false);
+    const promiseResult = requestTryAndRepeatUntil(
+      apiCall,
+      { max: 3, slowThreshold: 0 },
+      stopRepeat
+    );
+
+    expect(promiseResult).rejects.toBe(undefined);
+
+    for (let i = 1; i < 3; i++) {
+      jest.runAllTimers();
+      expect(apiCall).toBeCalledTimes(i);
+      await new Promise(setImmediate);
+    }
+    apiCall.mockResolvedValue('Success');
+    jest.runAllTimers();
+    expect(apiCall).toBeCalledTimes(3);
+  });
+
+  it('should slow down after 2 calls', async () => {
+    const apiCall = jest.fn().mockResolvedValue({});
+    const stopRepeat = jest.fn().mockReturnValue(false);
+    requestTryAndRepeatUntil(apiCall, { max: 5, slowThreshold: 3 }, stopRepeat);
+
+    for (let i = 1; i < 3; i++) {
+      jest.advanceTimersByTime(500);
+      expect(apiCall).toBeCalledTimes(i);
+      await new Promise(setImmediate);
+    }
+
+    jest.advanceTimersByTime(500);
+    expect(apiCall).toBeCalledTimes(2);
+    jest.advanceTimersByTime(2000);
+    expect(apiCall).toBeCalledTimes(2);
+    jest.advanceTimersByTime(500);
+    expect(apiCall).toBeCalledTimes(3);
+    await new Promise(setImmediate);
+
+    jest.advanceTimersByTime(3000);
+    expect(apiCall).toBeCalledTimes(4);
+  });
+});
+
+describe('checkStatus', () => {
+  it('should resolve with the response', () => {
+    const response = mockResponse();
+    return expect(checkStatus(response)).resolves.toBe(response);
+  });
+
+  it('should reject with the response', () => {
+    const response = mockResponse({}, 500);
+    return expect(checkStatus(response)).rejects.toEqual({ response });
+  });
+
+  it('should handle required authentication', () => {
+    return checkStatus(mockResponse({}, 401)).catch(() => {
+      expect(handleRequiredAuthentication).toBeCalled();
+    });
+  });
+
+  it('should reload the page when version is changing', async () => {
+    const reload = jest.fn();
+    delete window.location;
+    (window as any).location = { reload };
+
+    await checkStatus(mockResponse({ 'Sonar-Version': '6.7' }));
+    expect(reload).not.toBeCalled();
+    await checkStatus(mockResponse({ 'Sonar-Version': '6.7' }));
+    expect(reload).not.toBeCalled();
+    checkStatus(mockResponse({ 'Sonar-Version': '7.9' }));
+    expect(reload).toBeCalled();
+  });
+
+  function mockResponse(headers: T.Dict<string> = {}, status = 200): any {
+    return {
+      headers: { get: (prop: string) => headers[prop] },
+      status
+    };
+  }
+});
+
+describe('request functions', () => {
+  window.fetch = jest.fn();
+
+  beforeEach(() => {
+    (window.fetch as jest.Mock).mockReset();
+  });
+
+  const jsonResponse = '{"foo": "bar"}';
+
+  it('getJSON should return correctly', () => {
+    const response = new Response(jsonResponse, { status: 200 });
+
+    (window.fetch as jest.Mock).mockResolvedValue(response);
+
+    return request.getJSON('/api/foo', { q: 'a' }).then(response => {
+      expect(response).toEqual({ foo: 'bar' });
+    });
+  });
+
+  it('postJSON should return correctly', () => {
+    const response = new Response(jsonResponse, { status: 200 });
+
+    (window.fetch as jest.Mock).mockResolvedValue(response);
+
+    return request.postJSON('/api/foo', { q: 'a' }).then(response => {
+      expect(response).toEqual({ foo: 'bar' });
+    });
+  });
+
+  it('post should return correctly', () => {
+    const response = new Response(null, { status: 200 });
+
+    (window.fetch as jest.Mock).mockResolvedValue(response);
+
+    return request.post('/api/foo', { q: 'a' }).then(response => {
+      expect(response).toBeUndefined();
+    });
+  });
+
+  it('post should handle FormData correctly', () => {
+    const response = new Response(null, { status: 200 });
+
+    (window.fetch as jest.Mock).mockResolvedValue(response);
+
+    const data = new FormData();
+    data.set('q', 'a');
+
+    return request.post('/api/foo', data).then(response => {
+      expect(response).toBeUndefined();
+    });
+  });
+
+  it('requestDelete should return correctly', () => {
+    const response = new Response('ha!', { status: 200 });
+
+    (window.fetch as jest.Mock).mockResolvedValue(response);
+
+    return request.requestDelete('/api/foo', { q: 'a' }).then(response => {
+      expect(response).toBe(response);
+    });
+  });
+
+  it('getCorsJSON should return correctly', () => {
+    const response = new Response(jsonResponse, { status: 200 });
+
+    (window.fetch as jest.Mock).mockResolvedValue(response);
+
+    return request.getCorsJSON('/api/foo').then(response => {
+      expect(response).toEqual({ foo: 'bar' });
+    });
+  });
+
+  it('getCorsJSON should reject correctly', () => {
+    const response = new Response(jsonResponse, { status: 418 });
+
+    (window.fetch as jest.Mock).mockResolvedValue(response);
+
+    return request
+      .getCorsJSON('/api/foo')
+      .then(() => {
+        fail('should throw');
+      })
+      .catch(error => {
+        expect(error.response).toBe(response);
+      });
+  });
+});
diff --git a/server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts b/server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts
new file mode 100644 (file)
index 0000000..5aec449
--- /dev/null
@@ -0,0 +1,337 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { stringify } from 'querystring';
+import { omitBy, isNil } from 'lodash';
+import { getCookie } from 'sonar-ui-common/helpers/cookies';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+
+/*
+  WARNING /!\ WARNING
+
+  This is a snapshot of requests.ts when it was extracted to sonar-ui-common
+
+  It's sole purpose is to not break the compatibility with 3rd party extensions that might be using
+  the various helpers.
+
+  Do not use these functions, but rather the ones from sonar-ui-common/helpers/request.ts
+*/
+
+/** Current application version. Can be changed if a newer version is deployed. */
+let currentApplicationVersion: string | undefined;
+
+function getCSRFTokenName(): string {
+  return 'X-XSRF-TOKEN';
+}
+
+function getCSRFTokenValue(): string {
+  const cookieName = 'XSRF-TOKEN';
+  const cookieValue = getCookie(cookieName);
+  if (!cookieValue) {
+    return '';
+  }
+  return cookieValue;
+}
+
+/**
+ * Return an object containing a special http request header used to prevent CSRF attacks.
+ */
+function getCSRFToken(): T.Dict<string> {
+  // Fetch API in Edge doesn't work with empty header,
+  // so we ensure non-empty value
+  const value = getCSRFTokenValue();
+  return value ? { [getCSRFTokenName()]: value } : {};
+}
+
+type RequestData = T.Dict<any>;
+
+function omitNil(obj: RequestData): RequestData {
+  return omitBy(obj, isNil);
+}
+
+/**
+ * Default options for any request
+ */
+const DEFAULT_OPTIONS: {
+  credentials: RequestCredentials;
+  method: string;
+} = {
+  credentials: 'same-origin',
+  method: 'GET'
+};
+
+/**
+ * Default request headers
+ */
+const DEFAULT_HEADERS = {
+  Accept: 'application/json'
+};
+
+/**
+ * Request
+ */
+class Request {
+  private data?: RequestData;
+
+  constructor(private readonly url: string, private readonly options: { method?: string } = {}) {}
+
+  getSubmitData(customHeaders: any = {}): { url: string; options: RequestInit } {
+    let { url } = this;
+    const options: RequestInit = { ...DEFAULT_OPTIONS, ...this.options };
+
+    if (this.data) {
+      if (this.data instanceof FormData) {
+        options.body = this.data;
+      } else {
+        const strData = stringify(omitNil(this.data));
+        if (options.method === 'GET') {
+          url += '?' + strData;
+        } else {
+          customHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
+          options.body = strData;
+        }
+      }
+    }
+
+    options.headers = {
+      ...DEFAULT_HEADERS,
+      ...customHeaders
+    };
+    return { url, options };
+  }
+
+  submit(): Promise<Response> {
+    const { url, options } = this.getSubmitData({ ...getCSRFToken() });
+    return window.fetch(((window as any).baseUrl as string) + url, options);
+  }
+
+  setMethod(method: string): Request {
+    this.options.method = method;
+    return this;
+  }
+
+  setData(data?: RequestData): Request {
+    if (data) {
+      this.data = data;
+    }
+    return this;
+  }
+}
+
+/**
+ * Make a request
+ */
+function request(url: string): Request {
+  return new Request(url);
+}
+
+/**
+ * Make a cors request
+ */
+function corsRequest(url: string, mode: RequestMode = 'cors'): Request {
+  const options: RequestInit = { mode };
+  const request = new Request(url, options);
+  request.submit = function() {
+    const { url, options } = this.getSubmitData();
+    return window.fetch(url, options);
+  };
+  return request;
+}
+
+function checkApplicationVersion(response: Response): boolean {
+  const version = response.headers.get('Sonar-Version');
+  if (version) {
+    if (currentApplicationVersion && currentApplicationVersion !== version) {
+      window.location.reload();
+      return false;
+    } else {
+      currentApplicationVersion = version;
+    }
+  }
+  return true;
+}
+
+/**
+ * Check that response status is ok
+ */
+function checkStatus(response: Response): Promise<Response> {
+  return new Promise((resolve, reject) => {
+    if (checkApplicationVersion(response)) {
+      if (response.status === 401) {
+        import('sonar-ui-common/helpers/handleRequiredAuthentication')
+          .then(i => i.default())
+          .then(reject, reject);
+      } else if (response.status >= 200 && response.status < 300) {
+        resolve(response);
+      } else {
+        reject({ response });
+      }
+    }
+  });
+}
+
+/**
+ * Parse response as JSON
+ */
+function parseJSON(response: Response): Promise<any> {
+  return response.json();
+}
+
+/**
+ * Parse response of failed request
+ */
+function parseError(error: { response: Response }): Promise<string> {
+  const DEFAULT_MESSAGE = translate('default_error_message');
+  /* eslint-disable-next-line promise/no-promise-in-callback*/
+  return parseJSON(error.response)
+    .then(({ errors }) => errors.map((error: any) => error.msg).join('. '))
+    .catch(() => DEFAULT_MESSAGE);
+}
+
+/**
+ * Shortcut to do a GET request and return response json
+ */
+function getJSON(url: string, data?: RequestData): Promise<any> {
+  return request(url)
+    .setData(data)
+    .submit()
+    .then(checkStatus)
+    .then(parseJSON);
+}
+
+/**
+ * Shortcut to do a CORS GET request and return responsejson
+ */
+function getCorsJSON(url: string, data?: RequestData): Promise<any> {
+  return corsRequest(url)
+    .setData(data)
+    .submit()
+    .then(response => {
+      if (response.status >= 200 && response.status < 300) {
+        return parseJSON(response);
+      } else {
+        return Promise.reject({ response });
+      }
+    });
+}
+
+/**
+ * Shortcut to do a POST request and return response json
+ */
+function postJSON(url: string, data?: RequestData): Promise<any> {
+  return request(url)
+    .setMethod('POST')
+    .setData(data)
+    .submit()
+    .then(checkStatus)
+    .then(parseJSON);
+}
+
+/**
+ * Shortcut to do a POST request
+ */
+function post(url: string, data?: RequestData): Promise<void> {
+  return new Promise((resolve, reject) => {
+    request(url)
+      .setMethod('POST')
+      .setData(data)
+      .submit()
+      .then(checkStatus)
+      .then(() => {
+        resolve();
+      }, reject);
+  });
+}
+
+/**
+ * Shortcut to do a DELETE request and return response json
+ */
+function requestDelete(url: string, data?: RequestData): Promise<any> {
+  return request(url)
+    .setMethod('DELETE')
+    .setData(data)
+    .submit()
+    .then(checkStatus);
+}
+
+/**
+ * Delay promise for testing purposes
+ */
+function delay(response: any): Promise<any> {
+  return new Promise(resolve => setTimeout(() => resolve(response), 1200));
+}
+
+function tryRequestAgain<T>(
+  repeatAPICall: () => Promise<T>,
+  tries: { max: number; slowThreshold: number },
+  stopRepeat: (response: T) => boolean,
+  repeatErrors: number[] = []
+) {
+  tries.max--;
+  if (tries.max !== 0) {
+    return new Promise<T>(resolve => {
+      setTimeout(
+        () => resolve(requestTryAndRepeatUntil(repeatAPICall, tries, stopRepeat, repeatErrors)),
+        tries.max > tries.slowThreshold ? 500 : 3000
+      );
+    });
+  }
+  return Promise.reject();
+}
+
+function requestTryAndRepeatUntil<T>(
+  repeatAPICall: () => Promise<T>,
+  tries: { max: number; slowThreshold: number },
+  stopRepeat: (response: T) => boolean,
+  repeatErrors: number[] = []
+) {
+  return repeatAPICall().then(
+    r => {
+      if (stopRepeat(r)) {
+        return r;
+      }
+      return tryRequestAgain(repeatAPICall, tries, stopRepeat, repeatErrors);
+    },
+    (error: { response: Response }) => {
+      if (repeatErrors.length === 0 || repeatErrors.includes(error.response.status)) {
+        return tryRequestAgain(repeatAPICall, tries, stopRepeat, repeatErrors);
+      }
+      return Promise.reject(error);
+    }
+  );
+}
+
+export default {
+  checkStatus,
+  corsRequest,
+  delay,
+  getCorsJSON,
+  getCSRFToken,
+  getCSRFTokenName,
+  getCSRFTokenValue,
+  getJSON,
+  omitNil,
+  parseError,
+  parseJSON,
+  post,
+  postJSON,
+  request,
+  requestDelete,
+  requestTryAndRepeatUntil
+};
index 8d29b4bcad9f379e8d27dea11aaf7c2e15dbdaac..14581c4da90714514574d78a188c91fa54d06c59 100644 (file)
 import getStore from '../getStore';
 import throwGlobalError from '../throwGlobalError';
 
+jest.useFakeTimers();
+
 it('should put the error message in the store', async () => {
-  const response: any = { json: jest.fn().mockResolvedValue({ errors: [{ msg: 'error 1' }] }) };
-  await throwGlobalError({ response }).catch(() => {});
+  const response = new Response();
+  response.json = jest.fn().mockResolvedValue({ errors: [{ msg: 'error 1' }] });
+
+  // We need to catch because throwGlobalError rethrows after displaying the message
+  await throwGlobalError(response)
+    .then(() => fail('Should throw'))
+    .catch(() => {});
+
   expect(getStore().getState().globalMessages[0]).toMatchObject({
     level: 'ERROR',
     message: 'error 1'
@@ -30,10 +38,42 @@ it('should put the error message in the store', async () => {
 });
 
 it('should put a default error messsage in the store', async () => {
-  const response: any = { json: jest.fn().mockResolvedValue({}) };
-  await throwGlobalError({ response }).catch(() => {});
+  const response = new Response();
+  response.json = jest.fn().mockResolvedValue({});
+
+  // We need to catch because throwGlobalError rethrows after displaying the message
+  await throwGlobalError(response)
+    .then(() => fail('Should throw'))
+    .catch(() => {});
+
   expect(getStore().getState().globalMessages[0]).toMatchObject({
     level: 'ERROR',
     message: 'default_error_message'
   });
 });
+
+it('should handle weird response types', () => {
+  const response = { weird: 'response type' };
+
+  return throwGlobalError(response)
+    .then(() => fail('Should throw'))
+    .catch(error => {
+      expect(error).toBe(response);
+    });
+});
+
+it('should unwrap response if necessary', async () => {
+  const response = new Response();
+  response.json = jest.fn().mockResolvedValue({});
+
+  /* eslint-disable-next-line no-console */
+  console.warn = jest.fn();
+
+  // We need to catch because throwGlobalError rethrows after displaying the message
+  await throwGlobalError({ response })
+    .then(() => fail('Should throw'))
+    .catch(() => {});
+
+  /* eslint-disable-next-line no-console */
+  expect(console.warn).toHaveBeenCalled();
+});
index 63338038456473e13c578425e7bc1c37c76ddf09..03e9a50f31367f0095f60d3f9891d57bbcd4e96a 100644 (file)
@@ -21,15 +21,25 @@ import { parseError } from 'sonar-ui-common/helpers/request';
 import { addGlobalErrorMessage } from '../../store/globalMessages';
 import getStore from './getStore';
 
-export default function throwGlobalError(error: { response: Response }): Promise<Response> {
+export default function throwGlobalError(param: Response | any): Promise<Response | any> {
   const store = getStore();
 
-  // eslint-disable-next-line promise/no-promise-in-callback
-  parseError(error).then(
-    message => {
-      store.dispatch(addGlobalErrorMessage(message));
-    },
-    () => {}
-  );
-  return Promise.reject(error.response);
+  if (param.response instanceof Response) {
+    /* eslint-disable-next-line no-console */
+    console.warn('DEPRECATED: response should not be wrapped, pass it directly.');
+    param = param.response;
+  }
+
+  if (param instanceof Response) {
+    return parseError(param)
+      .then(
+        message => {
+          store.dispatch(addGlobalErrorMessage(message));
+        },
+        () => {}
+      )
+      .then(() => Promise.reject(param));
+  }
+
+  return Promise.reject(param);
 }
index dd1a311650f5259c1c75a236ae3cf0b4c261a457..6218f6d3189afdc42ab8aa602912cf95db4538e7 100644 (file)
@@ -61,8 +61,8 @@ export class EmailForm extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  handleError = (error: { response: Response }) => {
-    return parseError(error).then(message => {
+  handleError = (response: Response) => {
+    return parseError(response).then(message => {
       if (this.mounted) {
         this.setState({ error: message, loading: false });
       }
index 0611ca45b8839a89a8b043a34e6eadbfc7910af1..dd0b1e5131675263a05498b0d5b53a7ea3c40a3e 100644 (file)
@@ -130,9 +130,9 @@ export function resetValue(key: string, component?: string) {
 }
 
 function handleError(key: string, dispatch: Dispatch) {
-  return (error: { response: Response }) => {
+  return (response: Response) => {
     dispatch(stopLoading(key));
-    return parseError(error).then(message => {
+    return parseError(response).then(message => {
       dispatch(failValidation(key, message));
       return Promise.reject();
     });
index c0069ce4b4329a291572f308e1f7f96c9bff88f3..40f0d382f96c0c2cb900c8d0db6c93c5da43b1f0 100644 (file)
@@ -58,11 +58,11 @@ export default class PasswordForm extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  handleError = (error: { response: Response }) => {
-    if (!this.mounted || error.response.status !== 400) {
-      return throwGlobalError(error);
+  handleError = (response: Response) => {
+    if (!this.mounted || response.status !== 400) {
+      return throwGlobalError(response);
     } else {
-      return parseError(error).then(
+      return parseError(response).then(
         errorMsg => this.setState({ error: errorMsg, submitting: false }),
         throwGlobalError
       );
index dd7a279935297bd6ec96a3c80caa7d6603501fa3..d25eece1a4f75add2c2fa05d7683ebf2ab3d0727 100644 (file)
@@ -76,11 +76,11 @@ export default class UserForm extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  handleError = (error: { response: Response }) => {
-    if (!this.mounted || ![400, 500].includes(error.response.status)) {
-      return throwGlobalError(error);
+  handleError = (response: Response) => {
+    if (!this.mounted || ![400, 500].includes(response.status)) {
+      return throwGlobalError(response);
     } else {
-      return parseError(error).then(
+      return parseError(response).then(
         errorMsg => this.setState({ error: errorMsg }),
         throwGlobalError
       );
index 8fed4d531dfa8e31158d6b47e2f32e158a5a2779..5d6b61c049fa8cd184683ad5d9af5074f98974d6 100644 (file)
@@ -21,13 +21,59 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import { mockUser } from '../../../../helpers/testMocks';
 import PasswordForm from '../PasswordForm';
+import { changePassword } from '../../../../api/users';
+
+const password = 'new password asdf';
+
+jest.mock('../../../../api/users', () => ({
+  changePassword: jest.fn(() => Promise.resolve())
+}));
 
 it('should render correctly', () => {
   expect(shallowRender()).toMatchSnapshot();
 });
 
+it('should handle password change', async () => {
+  const onClose = jest.fn();
+  const wrapper = shallowRender({ onClose });
+
+  wrapper.setState({ newPassword: password, confirmPassword: password });
+  wrapper.instance().handleChangePassword({ preventDefault: jest.fn() } as any);
+
+  await new Promise(setImmediate);
+
+  expect(onClose).toHaveBeenCalled();
+});
+
+it('should handle password change error', async () => {
+  const wrapper = shallowRender();
+
+  (changePassword as jest.Mock).mockRejectedValue(new Response(undefined, { status: 400 }));
+
+  wrapper.setState({ newPassword: password, confirmPassword: password });
+  wrapper.instance().mounted = true;
+  wrapper.instance().handleChangePassword({ preventDefault: jest.fn() } as any);
+
+  await new Promise(setImmediate);
+
+  expect(wrapper.state('error')).toBe('default_error_message');
+});
+
+it('should handle form changes', () => {
+  const wrapper = shallowRender();
+
+  wrapper.instance().handleConfirmPasswordChange({ currentTarget: { value: 'pwd' } } as any);
+  expect(wrapper.state('confirmPassword')).toBe('pwd');
+
+  wrapper.instance().handleNewPasswordChange({ currentTarget: { value: 'pwd' } } as any);
+  expect(wrapper.state('newPassword')).toBe('pwd');
+
+  wrapper.instance().handleOldPasswordChange({ currentTarget: { value: 'pwd' } } as any);
+  expect(wrapper.state('oldPassword')).toBe('pwd');
+});
+
 function shallowRender(props: Partial<PasswordForm['props']> = {}) {
-  return shallow(
+  return shallow<PasswordForm>(
     <PasswordForm isCurrentUser={true} onClose={jest.fn()} user={mockUser()} {...props} />
   );
 }
index db97e0cd3b9891140dd4333600c439a6aafdd6ce..3bffac08ef7233ac15f466bde24731bb124d6835 100644 (file)
@@ -39,12 +39,11 @@ it('should render correctly', () => {
 });
 
 it('should correctly show errors', async () => {
-  (updateUser as jest.Mock).mockRejectedValue({
-    response: {
-      status: 400,
-      json: jest.fn().mockRejectedValue(undefined)
-    }
-  });
+  const response = new Response(null, { status: 400 });
+  response.json = jest.fn().mockRejectedValue(undefined);
+
+  (updateUser as jest.Mock).mockRejectedValue(response);
+
   const wrapper = shallowRender();
   submit(wrapper.dive().find('form'));
   await waitAndUpdate(wrapper);
index 617a22beccebf9f53a21ce5ee8859865684d1352..7c3ded4553337714edcf3cf234847e7fd4db636f 100644 (file)
@@ -6361,6 +6361,11 @@ lodash@4.17.11, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.3
   resolved "https://repox.jfrog.io/repox/api/npm/npm/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
   integrity sha1-s56mIp72B+zYniyN8SU2iRysm40=
 
+lodash@4.17.14:
+  version "4.17.14"
+  resolved "https://repox.jfrog.io/repox/api/npm/npm/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
+  integrity sha1-nOSHrmbJYlT+ILWZ8htoFgKAeLo=
+
 log-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://repox.jfrog.io/repox/api/npm/npm/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
@@ -9263,10 +9268,10 @@ sockjs@0.3.19:
     faye-websocket "^0.10.0"
     uuid "^3.0.1"
 
-sonar-ui-common@0.0.14:
-  version "0.0.14"
-  resolved "https://repox.jfrog.io/repox/api/npm/npm/sonar-ui-common/-/sonar-ui-common-0.0.14.tgz#56faa2ba62503c206e9894f55f36bd9ff4934257"
-  integrity sha1-VvqiumJQPCBumJT1Xza9n/STQlc=
+sonar-ui-common@0.0.18:
+  version "0.0.18"
+  resolved "https://repox.jfrog.io/repox/api/npm/npm/sonar-ui-common/-/sonar-ui-common-0.0.18.tgz#93b71859f83b85cc23e8c201bb9ed0420fcbb479"
+  integrity sha1-k7cYWfg7hcwj6MIBu57QQg/LtHk=
   dependencies:
     "@types/react-select" "1.2.6"
     classnames "2.2.6"
@@ -9280,7 +9285,8 @@ sonar-ui-common@0.0.14:
     date-fns "1.30.1"
     formik "1.2.0"
     history "3.3.0"
-    lodash "4.17.11"
+    lodash "4.17.14"
+    prop-types "15.7.2"
     react-draggable "3.2.1"
     react-intl "2.8.0"
     react-modal "3.8.2"