Просмотр исходного кода

SONAR-12245 Fix throwGlobalError and checkStatus

tags/8.0
Jeremy Davis 4 лет назад
Родитель
Сommit
58391f645a

+ 1
- 1
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": {

+ 12
- 6
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"

+ 1
- 1
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"

+ 3
- 3
server/sonar-web/src/main/js/api/marketplace.ts Просмотреть файл

@@ -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);
});
}

+ 8
- 27
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<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<

+ 4
- 4
server/sonar-web/src/main/js/api/rules.ts Просмотреть файл

@@ -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);
}
}
);

+ 1
- 1
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;

+ 298
- 0
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<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);
});
});
});

+ 337
- 0
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<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
};

+ 44
- 4
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();
});

+ 19
- 9
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<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);
}

+ 2
- 2
server/sonar-web/src/main/js/apps/settings/components/EmailForm.tsx Просмотреть файл

@@ -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 });
}

+ 2
- 2
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();
});

+ 4
- 4
server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx Просмотреть файл

@@ -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
);

+ 4
- 4
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx Просмотреть файл

@@ -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
);

+ 47
- 1
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<PasswordForm['props']> = {}) {
return shallow(
return shallow<PasswordForm>(
<PasswordForm isCurrentUser={true} onClose={jest.fn()} user={mockUser()} {...props} />
);
}

+ 5
- 6
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);

+ 11
- 5
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"

Загрузка…
Отмена
Сохранить