From 4bf292bac1d4dae9b8338c464e88e9dac6ca4b03 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Fri, 9 Feb 2018 15:44:52 +0100 Subject: [PATCH] SONAR-10347 Add ability to browse webhook deliveries payloads --- server/sonar-web/src/main/js/api/webhooks.ts | 21 ++- .../nav/component/ComponentNavMenu.tsx | 3 +- .../OrganizationNavigationAdministration.tsx | 3 +- .../navigation/OrganizationNavigationMenu.tsx | 14 +- .../main/js/apps/webhooks/components/App.tsx | 43 ++++--- .../webhooks/components/CreateWebhookForm.tsx | 7 +- .../webhooks/components/DeliveriesForm.tsx | 120 ++++++++++++++++++ .../apps/webhooks/components/DeliveryItem.tsx | 119 +++++++++++++++++ .../apps/webhooks/components/PageActions.tsx | 6 +- .../webhooks/components/WebhookActions.tsx | 23 +++- .../apps/webhooks/components/WebhooksList.tsx | 3 +- .../components/__tests__/App-test.tsx | 73 ++++++++++- .../__tests__/DeliveriesForm-test.tsx | 74 +++++++++++ .../__tests__/DeliveryItem-test.tsx | 58 +++++++++ .../__tests__/WebhookActions-test.tsx | 6 + .../__tests__/__snapshots__/App-test.tsx.snap | 77 ++++++----- .../DeliveriesForm-test.tsx.snap | 107 ++++++++++++++++ .../__snapshots__/DeliveryItem-test.tsx.snap | 84 ++++++++++++ .../__snapshots__/PageActions-test.tsx.snap | 2 + .../WebhookActions-test.tsx.snap | 6 + .../__snapshots__/WebhooksList-test.tsx.snap | 16 +-- .../main/js/helpers/__tests__/urls-test.ts | 55 +++++++- server/sonar-web/src/main/js/helpers/urls.ts | 14 ++ .../resources/org/sonar/l10n/core.properties | 7 +- 24 files changed, 858 insertions(+), 83 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx create mode 100644 server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx create mode 100644 server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap diff --git a/server/sonar-web/src/main/js/api/webhooks.ts b/server/sonar-web/src/main/js/api/webhooks.ts index f23cb2f079b..a51a01c0948 100644 --- a/server/sonar-web/src/main/js/api/webhooks.ts +++ b/server/sonar-web/src/main/js/api/webhooks.ts @@ -19,7 +19,7 @@ */ import { getJSON, post, postJSON } from '../helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; -import { Webhook } from '../app/types'; +import { Webhook, WebhookDelivery, Paging } from '../app/types'; export function createWebhook(data: { name: string; @@ -48,3 +48,22 @@ export function updateWebhook(data: { }): Promise { return post('/api/webhooks/update', data).catch(throwGlobalError); } + +export function searchDeliveries(data: { + ceTaskId?: string; + componentKey?: string; + webhook?: string; + p?: number; + ps?: number; +}): Promise<{ + deliveries: WebhookDelivery[]; + paging: Paging; +}> { + return getJSON('/api/webhooks/deliveries', data).catch(throwGlobalError); +} + +export function getDelivery(data: { + deliveryId: string; +}): Promise<{ delivery: WebhookDelivery & { payload: string } }> { + return getJSON('/api/webhooks/delivery', data).catch(throwGlobalError); +} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx index bb56f7751a8..7771dce09bb 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx @@ -42,7 +42,8 @@ const SETTINGS_URLS = [ '/project/history', 'background_tasks', '/project/key', - '/project/deletion' + '/project/deletion', + '/project/webhooks' ]; interface Props { diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx index cc2b9631ee8..338494e7604 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx @@ -36,7 +36,8 @@ const ADMIN_PATHS = [ 'delete', 'permissions', 'permission_templates', - 'projects_management' + 'projects_management', + 'webhooks' ]; export default function OrganizationNavigationAdministration({ location, organization }: Props) { diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx index 2c7b4f8b863..bbce6164647 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx @@ -35,37 +35,37 @@ export default function OrganizationNavigationMenu({ location, organization }: P return (
  • - + {translate('projects.page')}
  • + }}> {translate('issues.page')}
  • - + {translate('quality_profiles.page')}
  • - + {translate('coding_rules.page')}
  • - + {translate('quality_gates.page')}
  • - + {translate('organization.members.page')}
  • diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx index d48a3690400..68a8f9d42bd 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx @@ -94,7 +94,10 @@ export default class App extends React.PureComponent { if (this.mounted) { this.setState(({ webhooks }) => ({ webhooks: webhooks.map( - webhook => (webhook.key === data.webhook ? { ...webhook, ...data } : webhook) + webhook => + webhook.key === data.webhook + ? { ...webhook, name: data.name, url: data.url } + : webhook ) })); } @@ -105,27 +108,29 @@ export default class App extends React.PureComponent { const { loading, webhooks } = this.state; return ( -
    + <> - - - - - {!loading && ( -
    - + + -
    - )} -
    + + + {!loading && ( +
    + +
    + )} + + ); } } diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx index 91a12c2ba7c..6a76095990f 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx @@ -22,6 +22,7 @@ import { FormikProps } from 'formik'; import ValidationModal from '../../../components/controls/ValidationModal'; import InputValidationField from '../../../components/controls/InputValidationField'; import { Webhook } from '../../../app/types'; +import { isUrl } from '../../../helpers/urls'; import { translate } from '../../../helpers/l10n'; interface Props { @@ -52,8 +53,8 @@ export default class CreateWebhookForm extends React.PureComponent { errors.url = translate('webhooks.url.required'); } else if (!url.startsWith('http://') && !url.startsWith('https://')) { errors.url = translate('webhooks.url.bad_protocol'); - } else if (url.indexOf(':', 6) > 0 && url.indexOf('@') <= 0) { - errors.url = translate('webhooks.url.bad_auth'); + } else if (!isUrl(url)) { + errors.url = translate('webhooks.url.bad_format'); } return errors; }; @@ -106,8 +107,8 @@ export default class CreateWebhookForm extends React.PureComponent { /> void; + webhook: Webhook; +} + +interface State { + deliveries: WebhookDelivery[]; + loading: boolean; + paging?: Paging; +} + +const PAGE_SIZE = 10; + +export default class DeliveriesForm extends React.PureComponent { + mounted: boolean = false; + state: State = { deliveries: [], loading: true }; + + componentDidMount() { + this.mounted = true; + this.fetchDeliveries(); + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchDeliveries = ({ webhook } = this.props) => { + searchDeliveries({ webhook: webhook.key, ps: PAGE_SIZE }).then(({ deliveries, paging }) => { + if (this.mounted) { + this.setState({ deliveries, loading: false, paging }); + } + }, this.stopLoading); + }; + + fetchMoreDeliveries = ({ webhook } = this.props) => { + const { paging } = this.state; + if (paging) { + this.setState({ loading: true }); + searchDeliveries({ webhook: webhook.key, p: paging.pageIndex + 1, ps: PAGE_SIZE }).then( + ({ deliveries, paging }) => { + if (this.mounted) { + this.setState((state: State) => ({ + deliveries: [...state.deliveries, ...deliveries], + loading: false, + paging + })); + } + }, + this.stopLoading + ); + } + }; + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + render() { + const { webhook } = this.props; + const { deliveries, loading, paging } = this.state; + const header = translateWithParameters('webhooks.deliveries_for_x', webhook.name); + + return ( + +
    +

    {header}

    +
    +
    + {deliveries.map(delivery => )} +
    + +
    + {paging !== undefined && ( + + )} +
    +
    + +
    +
    + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx new file mode 100644 index 00000000000..61cf8bbbcd0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx @@ -0,0 +1,119 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import CheckIcon from '../../../components/icons-components/CheckIcon'; +import ClearIcon from '../../../components/icons-components/ClearIcon'; +import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; +import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion'; +import CodeSnippet from '../../../components/common/CodeSnippet'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import { getDelivery } from '../../../api/webhooks'; +import { formatMeasure } from '../../../helpers/measures'; +import { translateWithParameters, translate } from '../../../helpers/l10n'; +import { WebhookDelivery } from '../../../app/types'; + +interface Props { + delivery: WebhookDelivery; +} + +interface State { + loading: boolean; + open: boolean; + payload?: string; +} + +export default class DeliveryItem extends React.PureComponent { + mounted: boolean = false; + state: State = { loading: false, open: false }; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchPayload = ({ delivery } = this.props) => { + this.setState({ loading: true }); + return getDelivery({ deliveryId: delivery.id }).then( + ({ delivery }) => { + if (this.mounted) { + this.setState({ payload: delivery.payload, loading: false }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + }; + + formatPayload = (payload: string) => { + try { + return JSON.stringify(JSON.parse(payload), undefined, 2); + } catch (error) { + return payload; + } + }; + + handleClick = () => { + if (!this.state.payload) { + this.fetchPayload(); + } + this.setState(({ open }) => ({ open: !open })); + }; + + render() { + const { delivery } = this.props; + const { loading, open, payload } = this.state; + + return ( + + delivery.success ? ( + + ) : ( + + ) + } + title={}> +
    +

    + {translateWithParameters('webhooks.delivery.response_x', delivery.httpStatus)} +

    +

    + {translateWithParameters( + 'webhooks.delivery.duration_x', + formatMeasure(delivery.durationMs, 'MILLISEC') + )} +

    +

    {translate('webhooks.delivery.payload')}

    + + {payload && } + +
    +
    + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx index 92df63c688b..3968f8bdfbe 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx @@ -62,14 +62,16 @@ export default class PageActions extends React.PureComponent { - + ); } return ( <> - {this.state.openCreate && ( diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx index db7c401f832..42351e01a1f 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx @@ -19,6 +19,7 @@ */ import * as React from 'react'; import CreateWebhookForm from './CreateWebhookForm'; +import DeliveriesForm from './DeliveriesForm'; import ActionsDropdown, { ActionsDropdownItem, ActionsDropdownDivider @@ -34,12 +35,13 @@ interface Props { } interface State { + deliveries: boolean; updating: boolean; } export default class WebhookActions extends React.PureComponent { mounted: boolean = false; - state: State = { updating: false }; + state: State = { deliveries: false, updating: false }; componentDidMount() { this.mounted = true; @@ -53,6 +55,14 @@ export default class WebhookActions extends React.PureComponent { return this.props.onDelete(this.props.webhook.key); }; + handleDeliveriesClick = () => { + this.setState({ deliveries: true }); + }; + + handleDeliveriesStop = () => { + this.setState({ deliveries: false }); + }; + handleUpdate = (data: { name: string; url: string }) => { return this.props.onUpdate({ ...data, webhook: this.props.webhook.key }); }; @@ -67,13 +77,18 @@ export default class WebhookActions extends React.PureComponent { render() { const { webhook } = this.props; - + // TODO Disable "Show deliveries" if there is no lastDelivery return ( <> {translate('update_verb')} + + {translate('webhooks.deliveries.show')} + { )} - + {this.state.deliveries && ( + + )} {this.state.updating && ( { {this.renderHeader()} - {webhooks.map(webhook => ( + {sortBy(webhooks, webhook => webhook.name.toLowerCase()).map(webhook => ( ({ - searchWebhooks: jest.fn(() => Promise.resolve({ webhooks: [] })) + createWebhook: jest.fn(() => + Promise.resolve({ webhook: { key: '3', name: 'baz', url: 'http://baz' } }) + ), + deleteWebhook: jest.fn(() => Promise.resolve()), + searchWebhooks: jest.fn(() => + Promise.resolve({ + webhooks: [ + { key: '1', name: 'foo', url: 'http://foo' }, + { key: '2', name: 'bar', url: 'http://bar' } + ] + }) + ), + updateWebhook: jest.fn(() => Promise.resolve()) })); const organization = { key: 'foo', name: 'Foo', projectVisibility: Visibility.Private }; const component = { key: 'bar', organization: 'foo', qualifier: 'TRK' }; beforeEach(() => { + (createWebhook as jest.Mock).mockClear(); + (deleteWebhook as jest.Mock).mockClear(); (searchWebhooks as jest.Mock).mockClear(); + (updateWebhook as jest.Mock).mockClear(); }); it('should be in loading status', () => { @@ -58,21 +78,21 @@ describe('should correctly fetch webhooks when', () => { }); it('on project scope', async () => { - shallow(); + shallow(); await new Promise(setImmediate); expect(searchWebhooks).toHaveBeenCalledWith({ project: component.key }); }); it('on organization scope', async () => { - shallow(); + shallow(); await new Promise(setImmediate); expect(searchWebhooks).toHaveBeenCalledWith({ organization: organization.key }); }); it('on project scope within an organization', async () => { - shallow(); + shallow(); await new Promise(setImmediate); expect(searchWebhooks).toHaveBeenCalledWith({ @@ -81,3 +101,46 @@ describe('should correctly fetch webhooks when', () => { }); }); }); + +it('should correctly handle webhook creation', async () => { + const webhook = { name: 'baz', url: 'http://baz' }; + const wrapper = shallow(); + (wrapper.instance() as App).handleCreate({ ...webhook }); + expect(createWebhook).lastCalledWith({ + ...webhook, + organization: organization.key, + project: undefined + }); + + await new Promise(setImmediate); + wrapper.update(); + expect(wrapper.state('webhooks')).toEqual([ + { key: '1', name: 'foo', url: 'http://foo' }, + { key: '2', name: 'bar', url: 'http://bar' }, + { key: '3', name: 'baz', url: 'http://baz' } + ]); +}); + +it('should correctly handle webhook deletion', async () => { + const wrapper = shallow(); + (wrapper.instance() as App).handleDelete('2'); + expect(deleteWebhook).lastCalledWith({ webhook: '2' }); + + await new Promise(setImmediate); + wrapper.update(); + expect(wrapper.state('webhooks')).toEqual([{ key: '1', name: 'foo', url: 'http://foo' }]); +}); + +it('should correctly handle webhook update', async () => { + const newValues = { webhook: '1', name: 'Cfoo', url: 'http://cfoo' }; + const wrapper = shallow(); + (wrapper.instance() as App).handleUpdate(newValues); + expect(updateWebhook).lastCalledWith(newValues); + + await new Promise(setImmediate); + wrapper.update(); + expect(wrapper.state('webhooks')).toEqual([ + { key: '1', name: 'Cfoo', url: 'http://cfoo' }, + { key: '2', name: 'bar', url: 'http://bar' } + ]); +}); diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx new file mode 100644 index 00000000000..cf7cc332f8f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx @@ -0,0 +1,74 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import { shallow } from 'enzyme'; +import DeliveriesForm from '../DeliveriesForm'; +import { searchDeliveries } from '../../../../api/webhooks'; + +jest.mock('../../../../api/webhooks', () => ({ + searchDeliveries: jest.fn(() => + Promise.resolve({ + deliveries: [ + { + at: '12.02.2018', + durationMs: 20, + httpStatus: 200, + id: '2', + success: true + }, + { + at: '11.02.2018', + durationMs: 122, + httpStatus: 500, + id: '1', + success: false + } + ], + paging: { + pageIndex: 1, + pageSize: 10, + total: 15 + } + }) + ) +})); + +const webhook = { key: '1', name: 'foo', url: 'http://foo.bar' }; + +beforeEach(() => { + (searchDeliveries as jest.Mock).mockClear(); +}); + +it('should render correctly', async () => { + const wrapper = getWrapper(); + expect(wrapper).toMatchSnapshot(); + + await new Promise(setImmediate); + expect(searchDeliveries as jest.Mock).lastCalledWith({ webhook: webhook.key, ps: 10 }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + + wrapper.find('ListFooter').prop('loadMore')(); + expect(searchDeliveries).lastCalledWith({ webhook: webhook.key, p: 2, ps: 10 }); +}); + +function getWrapper(props = {}) { + return shallow(); +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx new file mode 100644 index 00000000000..585df65cab5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx @@ -0,0 +1,58 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import { shallow } from 'enzyme'; +import DeliveryItem from '../DeliveryItem'; +import { getDelivery } from '../../../../api/webhooks'; + +jest.mock('../../../../api/webhooks', () => ({ + getDelivery: jest.fn(() => + Promise.resolve({ + delivery: { payload: '{ "success": true }' } + }) + ) +})); + +const delivery = { + at: '12.02.2018', + durationMs: 20, + httpStatus: 200, + id: '2', + success: true +}; + +beforeEach(() => { + (getDelivery as jest.Mock).mockClear(); +}); + +it('should render correctly', async () => { + const wrapper = getWrapper(); + expect(wrapper).toMatchSnapshot(); + + wrapper.find('BoxedGroupAccordion').prop('onClick')(); + await new Promise(setImmediate); + expect(getDelivery).lastCalledWith({ deliveryId: delivery.id }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +function getWrapper(props = {}) { + return shallow(); +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx index 51aa702e132..b4331ce8ce8 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx @@ -54,6 +54,12 @@ it('should display the delete webhook form', () => { expect(onDelete).lastCalledWith(webhook.key); }); +it('should display the deliveries form', () => { + const wrapper = getWrapper(); + click(wrapper.find('.js-webhook-deliveries')); + expect(wrapper.find('DeliveriesForm').exists()).toBeTruthy(); +}); + function getWrapper(props = {}) { return shallow( + - - - - + > + + + + `; exports[`should fetch webhooks and display them 1`] = ` -
    + - - -
    - + + + +
    + +
    -
    + `; diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap new file mode 100644 index 00000000000..42cff706441 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + +
    +

    + webhooks.deliveries_for_x.foo +

    +
    +
    +
    + +
    +
    +
    + +
    +
    +`; + +exports[`should render correctly 2`] = ` + +
    +

    + webhooks.deliveries_for_x.foo +

    +
    +
    + + +
    + +
    + +
    +
    + +
    +
    +`; diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap new file mode 100644 index 00000000000..350ab5714c6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + + } +> +
    +

    + webhooks.delivery.response_x.200 +

    +

    + webhooks.delivery.duration_x.20ms +

    +

    + webhooks.delivery.payload +

    + +
    +
    +`; + +exports[`should render correctly 2`] = ` + + } +> +
    +

    + webhooks.delivery.response_x.200 +

    +

    + webhooks.delivery.duration_x.20ms +

    +

    + webhooks.delivery.payload +

    + + + +
    +
    +`; diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap index abbb9ca3fb9..7ba2be6a55f 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap @@ -10,6 +10,7 @@ exports[`should not allow to create a new webhook 1`] = ` > @@ -25,6 +26,7 @@ exports[`should render correctly 1`] = ` diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap index a82a5f3ed35..e3d35952ba5 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap @@ -11,6 +11,12 @@ exports[`should render correctly 1`] = ` > update_verb + + webhooks.deliveries.show +
    diff --git a/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts index 5b457e11305..ecd54ab1adb 100644 --- a/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts +++ b/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts @@ -23,7 +23,8 @@ import { getPathUrlAsString, getProjectUrl, getQualityGatesUrl, - getQualityGateUrl + getQualityGateUrl, + isUrl } from '../urls'; const SIMPLE_COMPONENT_KEY = 'sonarqube'; @@ -113,3 +114,55 @@ describe('#getQualityGate(s)Url', () => { }); }); }); + +describe('#isUrl', () => { + it('should be valid', () => { + expect(isUrl('https://localhost')).toBeTruthy(); + expect(isUrl('https://localhost/')).toBeTruthy(); + expect(isUrl('https://localhost:9000')).toBeTruthy(); + expect(isUrl('https://localhost:9000/')).toBeTruthy(); + expect(isUrl('https://foo:bar@localhost:9000')).toBeTruthy(); + expect(isUrl('https://foo@localhost')).toBeTruthy(); + expect(isUrl('http://foo.com/blah_blah')).toBeTruthy(); + expect(isUrl('http://foo.com/blah_blah/')).toBeTruthy(); + expect(isUrl('http://www.example.com/wpstyle/?p=364')).toBeTruthy(); + expect(isUrl('https://www.example.com/foo/?bar=baz&inga=42&quux')).toBeTruthy(); + expect(isUrl('http://userid@example.com')).toBeTruthy(); + expect(isUrl('http://userid@example.com/')).toBeTruthy(); + expect(isUrl('http://userid:password@example.com:8080')).toBeTruthy(); + expect(isUrl('http://userid:password@example.com:8080/')).toBeTruthy(); + expect(isUrl('http://userid@example.com:8080')).toBeTruthy(); + expect(isUrl('http://userid@example.com:8080/')).toBeTruthy(); + expect(isUrl('http://userid:password@example.com')).toBeTruthy(); + expect(isUrl('http://userid:password@example.com/')).toBeTruthy(); + expect(isUrl('http://142.42.1.1/')).toBeTruthy(); + expect(isUrl('http://142.42.1.1:8080/')).toBeTruthy(); + expect(isUrl('http://foo.com/blah_(wikipedia)#cite-1')).toBeTruthy(); + expect(isUrl('http://foo.com/blah_(wikipedia)_blah#cite-1')).toBeTruthy(); + expect(isUrl('http://foo.com/(something)?after=parens')).toBeTruthy(); + expect(isUrl('http://code.google.com/events/#&product=browser')).toBeTruthy(); + expect(isUrl('http://j.mp')).toBeTruthy(); + expect(isUrl('http://foo.bar/?q=Test%20URL-encoded%20stuff')).toBeTruthy(); + expect(isUrl('http://1337.net')).toBeTruthy(); + expect(isUrl('http://a.b-c.de')).toBeTruthy(); + expect(isUrl('http://223.255.255.254')).toBeTruthy(); + expect(isUrl('https://foo_bar.example.com/')).toBeTruthy(); + }); + + it('should not be valid', () => { + expect(isUrl('http://')).toBeFalsy(); + expect(isUrl('http://?')).toBeFalsy(); + expect(isUrl('http://??')).toBeFalsy(); + expect(isUrl('http://??/')).toBeFalsy(); + expect(isUrl('http://#')).toBeFalsy(); + expect(isUrl('http://##')).toBeFalsy(); + expect(isUrl('http://##/')).toBeFalsy(); + expect(isUrl('//')).toBeFalsy(); + expect(isUrl('//a')).toBeFalsy(); + expect(isUrl('///a')).toBeFalsy(); + expect(isUrl('///')).toBeFalsy(); + expect(isUrl('foo.com')).toBeFalsy(); + expect(isUrl('http:// shouldfail.com')).toBeFalsy(); + expect(isUrl(':// should fail')).toBeFalsy(); + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/urls.ts b/server/sonar-web/src/main/js/helpers/urls.ts index 732c0b6a6be..2bde3a1184e 100644 --- a/server/sonar-web/src/main/js/helpers/urls.ts +++ b/server/sonar-web/src/main/js/helpers/urls.ts @@ -191,3 +191,17 @@ export function getHomePageUrl(homepage: HomePage) { // should never happen, but just in case... return '/projects'; } + +export function isUrl(url: string) { + if (!URL) { + const elem = document.createElement('a'); + elem.href = url; + return !!(elem.host && elem.hostname && elem.protocol); + } + try { + const parsedUrl = new URL(url); + return url.includes(parsedUrl.host); + } catch (error) { + return false; + } +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 8c4b0f0c56b..daf129eab20 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2818,6 +2818,11 @@ webhooks.create=Create Webhook webhooks.delete=Delete Webhook webhooks.delete.confirm=Are you sure you want to delete the webhook "{0}"? webhooks.description=Webhooks are used to notify external services when a project analysis is done. An HTTP POST request including a JSON payload is sent to each of the provided URLs. Learn more in the {url}. +webhooks.deliveries.show=Show recent deliveries +webhooks.deliveries_for_x=Recent deliveries for {0} +webhooks.delivery.duration_x=Duration: {0} +webhooks.delivery.payload=Payload: +webhooks.delivery.response_x=Response: {0} webhooks.documentation_link=Webhooks documentation webhooks.maximum_reached=You reached your maximum number of {0} webhooks. You can still update or delete an existing one. webhooks.name=Name @@ -2825,7 +2830,7 @@ webhooks.name.required=Name is required. webhooks.no_result=No webhook defined. webhooks.update=Update Webhook webhooks.url=URL -webhooks.url.bad_auth=Bad format of URL authentication. +webhooks.url.bad_format=Bad format of URL. webhooks.url.bad_protocol=URL must start with "http://" or "https://". webhooks.url.description=Server endpoint that will receive the webhook payload, for example: "http://my_server/foo". If HTTP Basic authentication is used, HTTPS is recommended to avoid man in the middle attacks. Example: "https://myLogin:myPassword@my_server/foo" webhooks.url.required=URL is required. \ No newline at end of file -- 2.39.5