diff --git a/server/sonar-vsts/package.json b/server/sonar-vsts/package.json index a8e245a716f7..338563630cc2 100644 --- a/server/sonar-vsts/package.json +++ b/server/sonar-vsts/package.json @@ -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": { diff --git a/server/sonar-vsts/yarn.lock b/server/sonar-vsts/yarn.lock index c965f96ea742..6ee2e3a7b89b 100644 --- a/server/sonar-vsts/yarn.lock +++ b/server/sonar-vsts/yarn.lock @@ -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" diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index 6ca51eb76a83..7d31341e47fe 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -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" diff --git a/server/sonar-web/src/main/js/api/marketplace.ts b/server/sonar-web/src/main/js/api/marketplace.ts index c0949f956937..0b309fa61b92 100644 --- a/server/sonar-web/src/main/js/api/marketplace.ts +++ b/server/sonar-web/src/main/js/api/marketplace.ts @@ -42,10 +42,10 @@ export function isValidLicense(): Promise<{ isValidLicense: boolean }> { } export function showLicense(): Promise { - 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); }); } diff --git a/server/sonar-web/src/main/js/api/quality-profiles.ts b/server/sonar-web/src/main/js/api/quality-profiles.ts index ce6ae007fab1..b1a5459d4fce 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -19,15 +19,7 @@ */ 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 { - 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 { - 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 { return getJSON('/api/qualityprofiles/inheritance', { profileKey }).catch(throwGlobalError); } -export function setDefaultProfile(profileKey: string): Promise { +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< diff --git a/server/sonar-web/src/main/js/api/rules.ts b/server/sonar-web/src/main/js/api/rules.ts index 7311da171a2b..954dac86fcf0 100644 --- a/server/sonar-web/src/main/js/api/rules.ts +++ b/server/sonar-web/src/main/js/api/rules.ts @@ -82,13 +82,13 @@ export function createRule(data: { }): Promise { 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); } } ); diff --git a/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts b/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts index b4901d5a2016..680a7f3cfb0c 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts +++ b/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts @@ -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 index 000000000000..cb6fb2a72a88 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/extensions/legacy/__tests__/request-legacy-test.ts @@ -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 = {}, 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 index 000000000000..5aec449ee589 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/extensions/legacy/request-legacy.ts @@ -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 { + // 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; + +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 { + 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 { + 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 { + return response.json(); +} + +/** + * Parse response of failed request + */ +function parseError(error: { response: Response }): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return request(url) + .setMethod('DELETE') + .setData(data) + .submit() + .then(checkStatus); +} + +/** + * Delay promise for testing purposes + */ +function delay(response: any): Promise { + return new Promise(resolve => setTimeout(() => resolve(response), 1200)); +} + +function tryRequestAgain( + repeatAPICall: () => Promise, + tries: { max: number; slowThreshold: number }, + stopRepeat: (response: T) => boolean, + repeatErrors: number[] = [] +) { + tries.max--; + if (tries.max !== 0) { + return new Promise(resolve => { + setTimeout( + () => resolve(requestTryAndRepeatUntil(repeatAPICall, tries, stopRepeat, repeatErrors)), + tries.max > tries.slowThreshold ? 500 : 3000 + ); + }); + } + return Promise.reject(); +} + +function requestTryAndRepeatUntil( + repeatAPICall: () => Promise, + 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 +}; diff --git a/server/sonar-web/src/main/js/app/utils/__tests__/throwGlobalError-test.ts b/server/sonar-web/src/main/js/app/utils/__tests__/throwGlobalError-test.ts index 8d29b4bcad9f..14581c4da907 100644 --- a/server/sonar-web/src/main/js/app/utils/__tests__/throwGlobalError-test.ts +++ b/server/sonar-web/src/main/js/app/utils/__tests__/throwGlobalError-test.ts @@ -20,9 +20,17 @@ 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(); +}); diff --git a/server/sonar-web/src/main/js/app/utils/throwGlobalError.ts b/server/sonar-web/src/main/js/app/utils/throwGlobalError.ts index 633380384564..03e9a50f3136 100644 --- a/server/sonar-web/src/main/js/app/utils/throwGlobalError.ts +++ b/server/sonar-web/src/main/js/app/utils/throwGlobalError.ts @@ -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 { +export default function throwGlobalError(param: Response | any): Promise { 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); } diff --git a/server/sonar-web/src/main/js/apps/settings/components/EmailForm.tsx b/server/sonar-web/src/main/js/apps/settings/components/EmailForm.tsx index dd1a311650f5..6218f6d3189a 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/EmailForm.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/EmailForm.tsx @@ -61,8 +61,8 @@ export class EmailForm extends React.PureComponent { 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 }); } diff --git a/server/sonar-web/src/main/js/apps/settings/store/actions.ts b/server/sonar-web/src/main/js/apps/settings/store/actions.ts index 0611ca45b883..dd0b1e513167 100644 --- a/server/sonar-web/src/main/js/apps/settings/store/actions.ts +++ b/server/sonar-web/src/main/js/apps/settings/store/actions.ts @@ -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(); }); diff --git a/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx b/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx index c0069ce4b432..40f0d382f96c 100644 --- a/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx @@ -58,11 +58,11 @@ export default class PasswordForm extends React.PureComponent { 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 ); diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx index dd7a27993529..d25eece1a4f7 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx @@ -76,11 +76,11 @@ export default class UserForm extends React.PureComponent { 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 ); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/PasswordForm-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/PasswordForm-test.tsx index 8fed4d531dfa..5d6b61c049fa 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/PasswordForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/PasswordForm-test.tsx @@ -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 = {}) { - return shallow( + return shallow( ); } diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx index db97e0cd3b98..3bffac08ef72 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx @@ -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); diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index 617a22becceb..7c3ded455333 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -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"