]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18835 Make webhook secret private (#8479)
authorDimitris <104737204+dimitris-kavvathas-sonarsource@users.noreply.github.com>
Thu, 15 Jun 2023 13:49:19 +0000 (15:49 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 3 Jul 2023 20:03:25 +0000 (20:03 +0000)
Co-authored-by: Ambroise C <ambroise.christea@sonarsource.com>
(cherry picked from commit 441a87e76b1fab056a23b1773364063ab50e6978)

35 files changed:
server/sonar-web/src/main/js/api/webhooks.ts
server/sonar-web/src/main/js/apps/webhooks/components/App.tsx
server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx
server/sonar-web/src/main/js/apps/webhooks/components/DeleteWebhookForm.tsx
server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx
server/sonar-web/src/main/js/apps/webhooks/components/LatestDeliveryForm.tsx
server/sonar-web/src/main/js/apps/webhooks/components/UpdateWebhookSecretField.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx
server/sonar-web/src/main/js/apps/webhooks/components/WebhookItem.tsx
server/sonar-web/src/main/js/apps/webhooks/components/WebhookItemLatestDelivery.tsx
server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/LatestDeliveryForm-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookItem-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookItemLatestDelivery-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhooksList-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/App-test.tsx.snap
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/CreateWebhookForm-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookItem-test.tsx.snap
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhooksList-test.tsx.snap
server/sonar-web/src/main/js/types/webhook.ts
server/sonar-webserver-webapi/src/main/java/org/sonar/server/webhook/ws/CreateAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/webhook/ws/ListAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/webhook/ws/UpdateAction.java
server/sonar-webserver-webapi/src/main/resources/org/sonar/server/webhook/ws/example-webhook-create.json
server/sonar-webserver-webapi/src/main/resources/org/sonar/server/webhook/ws/example-webhooks-list.json
server/sonar-webserver-webapi/src/test/java/org/sonar/server/webhook/ws/CreateActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/webhook/ws/ListActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/webhook/ws/UpdateActionTest.java
sonar-core/src/main/resources/org/sonar/l10n/core.properties
sonar-ws/src/main/protobuf/ws-webhooks.proto

index aebd5bf6a71919557e73aed2c6623a6ee2046e98..b2965b03faf9d6bf3b513cedaf9b435bff1eee45 100644 (file)
 import { throwGlobalError } from '../helpers/error';
 import { getJSON, post, postJSON } from '../helpers/request';
 import { Paging } from '../types/types';
-import { Webhook, WebhookDelivery } from '../types/webhook';
+import {
+  WebhookCreatePayload,
+  WebhookDelivery,
+  WebhookResponse,
+  WebhookUpdatePayload,
+} from '../types/webhook';
 
-export function createWebhook(data: {
-  name: string;
-  project?: string;
-  secret?: string;
-  url: string;
-}): Promise<{ webhook: Webhook }> {
+export function createWebhook(data: WebhookCreatePayload): Promise<{ webhook: WebhookResponse }> {
   return postJSON('/api/webhooks/create', data).catch(throwGlobalError);
 }
 
@@ -35,16 +35,13 @@ export function deleteWebhook(data: { webhook: string }): Promise<void | Respons
   return post('/api/webhooks/delete', data).catch(throwGlobalError);
 }
 
-export function searchWebhooks(data: { project?: string }): Promise<{ webhooks: Webhook[] }> {
+export function searchWebhooks(data: {
+  project?: string;
+}): Promise<{ webhooks: WebhookResponse[] }> {
   return getJSON('/api/webhooks/list', data).catch(throwGlobalError);
 }
 
-export function updateWebhook(data: {
-  webhook: string;
-  name: string;
-  secret?: string;
-  url: string;
-}): Promise<void | Response> {
+export function updateWebhook(data: WebhookUpdatePayload): Promise<void | Response> {
   return post('/api/webhooks/update', data).catch(throwGlobalError);
 }
 
index 6e88a09c912c3fa8d86fb2489466958826290ba5..3c02424500cbe6810eda0d451c5a0fe04ff03c01 100644 (file)
@@ -24,7 +24,7 @@ import withComponentContext from '../../../app/components/componentContext/withC
 import Suggestions from '../../../components/embed-docs-modal/Suggestions';
 import { translate } from '../../../helpers/l10n';
 import { Component } from '../../../types/types';
-import { Webhook } from '../../../types/webhook';
+import { WebhookResponse } from '../../../types/webhook';
 import PageActions from './PageActions';
 import PageHeader from './PageHeader';
 import WebhooksList from './WebhooksList';
@@ -36,7 +36,7 @@ interface Props {
 
 interface State {
   loading: boolean;
-  webhooks: Webhook[];
+  webhooks: WebhookResponse[];
 }
 
 export class App extends React.PureComponent<Props, State> {
@@ -99,19 +99,24 @@ export class App extends React.PureComponent<Props, State> {
   };
 
   handleUpdate = (data: { webhook: string; name: string; secret?: string; url: string }) => {
-    const udpateData = {
+    const updateData = {
       webhook: data.webhook,
       name: data.name,
       url: data.url,
-      ...(data.secret && { secret: data.secret }),
+      secret: data.secret,
     };
 
-    return updateWebhook(udpateData).then(() => {
+    return updateWebhook(updateData).then(() => {
       if (this.mounted) {
         this.setState(({ webhooks }) => ({
           webhooks: webhooks.map((webhook) =>
             webhook.key === data.webhook
-              ? { ...webhook, name: data.name, secret: data.secret, url: data.url }
+              ? {
+                  ...webhook,
+                  name: data.name,
+                  hasSecret: data.secret === undefined ? webhook.hasSecret : Boolean(data.secret),
+                  url: data.url,
+                }
               : webhook
           ),
         }));
index 5d1833ac179e58844d521a66eaf5ab74bdf5a071..602fdea89860c51f1c7c65c35bb1adc3021c0978 100644 (file)
@@ -24,18 +24,13 @@ import ValidationModal from '../../../components/controls/ValidationModal';
 import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
 import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
 import { translate } from '../../../helpers/l10n';
-import { Webhook } from '../../../types/webhook';
+import { WebhookBasePayload, WebhookResponse } from '../../../types/webhook';
+import UpdateWebhookSecretField from './UpdateWebhookSecretField';
 
 interface Props {
   onClose: () => void;
-  onDone: (data: Values) => Promise<void>;
-  webhook?: Webhook;
-}
-
-interface Values {
-  name: string;
-  secret: string;
-  url: string;
+  onDone: (data: WebhookBasePayload) => Promise<void>;
+  webhook?: WebhookResponse;
 }
 
 export default class CreateWebhookForm extends React.PureComponent<Props> {
@@ -45,7 +40,7 @@ export default class CreateWebhookForm extends React.PureComponent<Props> {
     this.props.onClose();
   };
 
-  handleValidate = (data: Values) => {
+  handleValidate = (data: WebhookBasePayload) => {
     const { name, secret, url } = data;
     const errors: { name?: string; secret?: string; url?: string } = {};
     if (!name.trim()) {
@@ -74,9 +69,9 @@ export default class CreateWebhookForm extends React.PureComponent<Props> {
         confirmButtonText={confirmButtonText}
         header={modalHeader}
         initialValues={{
-          name: (webhook && webhook.name) || '',
-          secret: (webhook && webhook.secret) || '',
-          url: (webhook && webhook.url) || '',
+          name: webhook?.name ?? '',
+          url: webhook?.url ?? '',
+          secret: isUpdate ? undefined : '',
         }}
         onClose={this.props.onClose}
         onSubmit={this.props.onDone}
@@ -125,12 +120,15 @@ export default class CreateWebhookForm extends React.PureComponent<Props> {
               type="text"
               value={values.url}
             />
-            <InputValidationField
-              description={translate('webhooks.secret.description')}
+            <UpdateWebhookSecretField
+              description={`${translate('webhooks.secret.description')}${
+                isUpdate ? ` ${translate('webhooks.secret.description.update')}` : ''
+              }`}
               dirty={dirty}
               disabled={isSubmitting}
               error={errors.secret}
               id="webhook-secret"
+              isUpdateForm={isUpdate}
               label={<label htmlFor="webhook-secret">{translate('webhooks.secret')}</label>}
               name="secret"
               onBlur={handleBlur}
index 6ec97f173ce744cf48f7080951906457b11ee7b6..238ec10c8b902ed7c14cc2c128affe562e0059ad 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import SimpleModal from '../../../components/controls/SimpleModal';
+import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { Webhook } from '../../../types/webhook';
+import { WebhookResponse } from '../../../types/webhook';
 
 interface Props {
   onClose: () => void;
   onSubmit: () => Promise<void>;
-  webhook: Webhook;
+  webhook: WebhookResponse;
 }
 
 export default function DeleteWebhookForm({ onClose, onSubmit, webhook }: Props) {
index 9dfad0d5b7121f0b7267c70d23b7c18e3391e846..85e70128724092c52b08cd271e192b27e21a29b5 100644 (file)
  */
 import * as React from 'react';
 import { searchDeliveries } from '../../../api/webhooks';
-import { ResetButtonLink } from '../../../components/controls/buttons';
 import ListFooter from '../../../components/controls/ListFooter';
 import Modal from '../../../components/controls/Modal';
+import { ResetButtonLink } from '../../../components/controls/buttons';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Paging } from '../../../types/types';
-import { Webhook, WebhookDelivery } from '../../../types/webhook';
+import { WebhookDelivery, WebhookResponse } from '../../../types/webhook';
 import DeliveryAccordion from './DeliveryAccordion';
 
 interface Props {
   onClose: () => void;
-  webhook: Webhook;
+  webhook: WebhookResponse;
 }
 
 interface State {
index d4dba71fc4229c48ec40006dca598449581bbbf2..ceedc15589c7d08768bfa9b4383dd5b1c6865b7b 100644 (file)
  */
 import * as React from 'react';
 import { getDelivery } from '../../../api/webhooks';
-import { ResetButtonLink } from '../../../components/controls/buttons';
 import Modal from '../../../components/controls/Modal';
+import { ResetButtonLink } from '../../../components/controls/buttons';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { Webhook, WebhookDelivery } from '../../../types/webhook';
+import { WebhookDelivery, WebhookResponse } from '../../../types/webhook';
 import DeliveryItem from './DeliveryItem';
 
 interface Props {
   delivery: WebhookDelivery;
   onClose: () => void;
-  webhook: Webhook;
+  webhook: WebhookResponse;
 }
 
 interface State {
diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/UpdateWebhookSecretField.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/UpdateWebhookSecretField.tsx
new file mode 100644 (file)
index 0000000..6c01c7b
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { useField } from 'formik';
+import * as React from 'react';
+import InputValidationField from '../../../components/controls/InputValidationField';
+import ModalValidationField from '../../../components/controls/ModalValidationField';
+import { ButtonLink } from '../../../components/controls/buttons';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  description?: string;
+  dirty: boolean;
+  disabled: boolean;
+  error: string | undefined;
+  id?: string;
+  isUpdateForm: boolean;
+  label?: React.ReactNode;
+  name: string;
+  onBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
+  onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  touched: boolean | undefined;
+  type?: string;
+  value?: string;
+}
+
+export default function UpdateWebhookSecretField({
+  isUpdateForm,
+  description,
+  dirty,
+  disabled,
+  error,
+  id,
+  label,
+  name,
+  onBlur,
+  onChange,
+  touched,
+  type,
+  value,
+}: Props) {
+  const [isSecretInputDisplayed, setIsSecretInputDisplayed] = React.useState(false);
+  const [, , { setValue: setSecretValue }] = useField('secret');
+
+  const showSecretInput = () => {
+    setSecretValue('');
+    setIsSecretInputDisplayed(true);
+  };
+
+  return !isUpdateForm || isSecretInputDisplayed ? (
+    <InputValidationField
+      description={description}
+      dirty={dirty}
+      disabled={disabled}
+      error={error}
+      id={id}
+      label={label}
+      name={name}
+      onBlur={onBlur}
+      onChange={onChange}
+      touched={touched}
+      type={type}
+      value={value as string}
+    />
+  ) : (
+    <ModalValidationField
+      description={description}
+      dirty={false}
+      error={undefined}
+      label={label}
+      touched={true}
+    >
+      {() => (
+        <div className="sw-mb-5 sw-leading-6 sw-flex sw-items-center">
+          <span className="sw-mr-1/2">{translate('webhooks.secret.field_mask.description')}</span>
+          <ButtonLink onClick={showSecretInput}>
+            {translate('webhooks.secret.field_mask.link')}
+          </ButtonLink>
+        </div>
+      )}
+    </ModalValidationField>
+  );
+}
index 4a97aa0b15cec937d563fb9d0441b30dddc51378..75a89cefd4f9fd4bc3db148bb43c79e5fed33062 100644 (file)
@@ -23,15 +23,15 @@ import ActionsDropdown, {
   ActionsDropdownItem,
 } from '../../../components/controls/ActionsDropdown';
 import { translate } from '../../../helpers/l10n';
-import { Webhook } from '../../../types/webhook';
+import { WebhookResponse, WebhookUpdatePayload } from '../../../types/webhook';
 import CreateWebhookForm from './CreateWebhookForm';
 import DeleteWebhookForm from './DeleteWebhookForm';
 import DeliveriesForm from './DeliveriesForm';
 
 interface Props {
   onDelete: (webhook: string) => Promise<void>;
-  onUpdate: (data: { webhook: string; name: string; url: string }) => Promise<void>;
-  webhook: Webhook;
+  onUpdate: (data: WebhookUpdatePayload) => Promise<void>;
+  webhook: WebhookResponse;
 }
 
 interface State {
@@ -74,7 +74,7 @@ export default class WebhookActions extends React.PureComponent<Props, State> {
     this.setState({ deliveries: false });
   };
 
-  handleUpdate = (data: { name: string; url: string }) => {
+  handleUpdate = (data: { name: string; secret?: string; url: string }) => {
     return this.props.onUpdate({ ...data, webhook: this.props.webhook.key });
   };
 
index 51ef3dc223655a7dbeb2bcbf9562c273c9ac0abc..1e69c832adc6535399d7e745d30a4762320cc953 100644 (file)
  */
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
-import { Webhook } from '../../../types/webhook';
+import { WebhookResponse, WebhookUpdatePayload } from '../../../types/webhook';
 import WebhookActions from './WebhookActions';
 import WebhookItemLatestDelivery from './WebhookItemLatestDelivery';
 
 interface Props {
   onDelete: (webhook: string) => Promise<void>;
-  onUpdate: (data: { webhook: string; name: string; url: string }) => Promise<void>;
-  webhook: Webhook;
+  onUpdate: (data: WebhookUpdatePayload) => Promise<void>;
+  webhook: WebhookResponse;
 }
 
 export default function WebhookItem({ onDelete, onUpdate, webhook }: Props) {
@@ -34,11 +34,11 @@ export default function WebhookItem({ onDelete, onUpdate, webhook }: Props) {
     <tr>
       <td>{webhook.name}</td>
       <td>{webhook.url}</td>
-      <td>{webhook.secret ? translate('yes') : translate('no')}</td>
+      <td>{webhook.hasSecret ? translate('yes') : translate('no')}</td>
       <td>
         <WebhookItemLatestDelivery webhook={webhook} />
       </td>
-      <td className="thin nowrap text-right">
+      <td className="sw-text-right">
         <WebhookActions onDelete={onDelete} onUpdate={onUpdate} webhook={webhook} />
       </td>
     </tr>
index d932cf8633aa00081256c052dd62ac7b7d801006..c896aa43738461fdc18deb404b0359994a4f5f5d 100644 (file)
@@ -24,11 +24,11 @@ import AlertSuccessIcon from '../../../components/icons/AlertSuccessIcon';
 import BulletListIcon from '../../../components/icons/BulletListIcon';
 import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import { translate } from '../../../helpers/l10n';
-import { Webhook } from '../../../types/webhook';
+import { WebhookResponse } from '../../../types/webhook';
 import LatestDeliveryForm from './LatestDeliveryForm';
 
 interface Props {
-  webhook: Webhook;
+  webhook: WebhookResponse;
 }
 
 interface State {
index 08135a70143ba7f06637d15ec63eb0a46093f335..b60aa9008c4466ceb0f03dc40ca6b5f3a7446aef 100644 (file)
 import { sortBy } from 'lodash';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
-import { Webhook } from '../../../types/webhook';
+import { WebhookResponse, WebhookUpdatePayload } from '../../../types/webhook';
 import WebhookItem from './WebhookItem';
 
 interface Props {
   onDelete: (webhook: string) => Promise<void>;
-  onUpdate: (data: { webhook: string; name: string; url: string }) => Promise<void>;
-  webhooks: Webhook[];
+  onUpdate: (data: WebhookUpdatePayload) => Promise<void>;
+  webhooks: WebhookResponse[];
 }
 
 export default class WebhooksList extends React.PureComponent<Props> {
@@ -37,7 +37,7 @@ export default class WebhooksList extends React.PureComponent<Props> {
         <th>{translate('webhooks.url')}</th>
         <th>{translate('webhooks.secret_header')}</th>
         <th>{translate('webhooks.last_execution')}</th>
-        <th />
+        <th className="sw-text-right">{translate('actions')}</th>
       </tr>
     </thead>
   );
index 69fd13fd6d45916c7535cff856dcb99aa96acb0b..1aa92f4b207ff0b14e4e698a44fbead5feba8a65 100644 (file)
@@ -30,14 +30,14 @@ import { App } from '../App';
 
 jest.mock('../../../../api/webhooks', () => ({
   createWebhook: jest.fn(() =>
-    Promise.resolve({ webhook: { key: '3', name: 'baz', url: 'http://baz' } })
+    Promise.resolve({ webhook: { key: '3', name: 'baz', url: 'http://baz', hasSecret: false } })
   ),
   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' },
+        { key: '1', name: 'foo', url: 'http://foo', hasSecret: false },
+        { key: '2', name: 'bar', url: 'http://bar', hasSecret: false },
       ],
     })
   ),
@@ -98,9 +98,9 @@ it('should correctly handle webhook creation', async () => {
   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' },
+    { key: '1', name: 'foo', url: 'http://foo', hasSecret: false },
+    { key: '2', name: 'bar', url: 'http://bar', hasSecret: false },
+    { key: '3', name: 'baz', url: 'http://baz', hasSecret: false },
   ]);
 });
 
@@ -111,11 +111,13 @@ it('should correctly handle webhook deletion', async () => {
 
   await new Promise(setImmediate);
   wrapper.update();
-  expect(wrapper.state('webhooks')).toEqual([{ key: '1', name: 'foo', url: 'http://foo' }]);
+  expect(wrapper.state('webhooks')).toEqual([
+    { key: '1', name: 'foo', url: 'http://foo', hasSecret: false },
+  ]);
 });
 
 it('should correctly handle webhook update', async () => {
-  const newValues = { webhook: '1', name: 'Cfoo', url: 'http://cfoo' };
+  const newValues = { webhook: '1', name: 'Cfoo', url: 'http://cfoo', secret: undefined };
   const wrapper = shallow(<App />);
   (wrapper.instance() as App).handleUpdate(newValues);
   expect(updateWebhook).toHaveBeenLastCalledWith(newValues);
@@ -123,7 +125,52 @@ it('should correctly handle webhook update', async () => {
   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' },
+    { key: '1', name: 'Cfoo', url: 'http://cfoo', hasSecret: false },
+    { key: '2', name: 'bar', url: 'http://bar', hasSecret: false },
+  ]);
+});
+
+it('should correctly handle webhook secret update', async () => {
+  const newValuesWithSecret = { webhook: '2', name: 'bar', url: 'http://bar', secret: 'secret' };
+  const newValuesWithoutSecret = {
+    webhook: '2',
+    name: 'bar',
+    url: 'http://bar',
+    secret: undefined,
+  };
+  const newValuesWithEmptySecret = { webhook: '2', name: 'bar', url: 'http://bar', secret: '' };
+  const wrapper = shallow(<App />);
+
+  // With secret
+  (wrapper.instance() as App).handleUpdate(newValuesWithSecret);
+  expect(updateWebhook).toHaveBeenLastCalledWith(newValuesWithSecret);
+
+  await new Promise(setImmediate);
+  wrapper.update();
+  expect(wrapper.state('webhooks')).toEqual([
+    { key: '1', name: 'foo', url: 'http://foo', hasSecret: false },
+    { key: '2', name: 'bar', url: 'http://bar', hasSecret: true },
+  ]);
+
+  // Without secret
+  (wrapper.instance() as App).handleUpdate(newValuesWithoutSecret);
+  expect(updateWebhook).toHaveBeenLastCalledWith(newValuesWithoutSecret);
+
+  await new Promise(setImmediate);
+  wrapper.update();
+  expect(wrapper.state('webhooks')).toEqual([
+    { key: '1', name: 'foo', url: 'http://foo', hasSecret: false },
+    { key: '2', name: 'bar', url: 'http://bar', hasSecret: true },
+  ]);
+
+  // With empty secret
+  (wrapper.instance() as App).handleUpdate(newValuesWithEmptySecret);
+  expect(updateWebhook).toHaveBeenLastCalledWith(newValuesWithEmptySecret);
+
+  await new Promise(setImmediate);
+  wrapper.update();
+  expect(wrapper.state('webhooks')).toEqual([
+    { key: '1', name: 'foo', url: 'http://foo', hasSecret: false },
+    { key: '2', name: 'bar', url: 'http://bar', hasSecret: false },
   ]);
 });
diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-it.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-it.tsx
new file mode 100644 (file)
index 0000000..c568758
--- /dev/null
@@ -0,0 +1,145 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+import { byLabelText, byRole } from 'testing-library-selector';
+import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import CreateWebhookForm from '../CreateWebhookForm';
+
+const ui = {
+  nameInput: byRole('textbox', { name: 'webhooks.name field_required' }),
+  urlInput: byRole('textbox', { name: 'webhooks.url field_required' }),
+  secretInput: byLabelText('webhooks.secret'),
+  secretInputMaskButton: byRole('button', { name: 'webhooks.secret.field_mask.link' }),
+  createButton: byRole('button', { name: 'create' }),
+  updateButton: byRole('button', { name: 'update_verb' }),
+};
+
+describe('Webhook form', () => {
+  it('should correctly submit creation form', async () => {
+    const user = userEvent.setup();
+    const webhook = {
+      name: 'foo',
+      url: 'http://bar',
+      secret: '',
+    };
+    const onDone = jest.fn();
+
+    renderCreateWebhookForm({ onDone });
+
+    expect(ui.nameInput.get()).toHaveValue('');
+    expect(ui.urlInput.get()).toHaveValue('');
+    expect(ui.secretInput.get()).toHaveValue('');
+    expect(ui.createButton.get()).toBeDisabled();
+
+    await user.type(ui.nameInput.get(), webhook.name);
+    await user.type(ui.urlInput.get(), webhook.url);
+    expect(ui.createButton.get()).toBeEnabled();
+
+    await user.click(ui.createButton.get());
+    expect(onDone).toHaveBeenCalledWith(webhook);
+  });
+
+  it('should correctly submit update form', async () => {
+    const user = userEvent.setup();
+    const webhook = {
+      hasSecret: false,
+      key: 'test-webhook-key',
+      name: 'foo',
+      url: 'http://bar',
+    };
+    const nameExtension = 'bar';
+    const url = 'http://bar';
+    const onDone = jest.fn();
+
+    renderCreateWebhookForm({ onDone, webhook });
+
+    expect(ui.nameInput.get()).toHaveValue(webhook.name);
+    expect(ui.urlInput.get()).toHaveValue(webhook.url);
+    expect(ui.secretInput.query()).not.toBeInTheDocument();
+    expect(ui.secretInputMaskButton.get()).toBeInTheDocument();
+    expect(ui.updateButton.get()).toBeDisabled();
+
+    await user.type(ui.nameInput.get(), nameExtension);
+    await user.clear(ui.urlInput.get());
+    await user.type(ui.urlInput.get(), url);
+    expect(ui.updateButton.get()).toBeEnabled();
+
+    await user.click(ui.updateButton.get());
+    expect(onDone).toHaveBeenCalledWith({
+      name: `${webhook.name}${nameExtension}`,
+      url,
+      secret: undefined,
+    });
+  });
+
+  it('should correctly submit update form with empty secret', async () => {
+    const user = userEvent.setup();
+    const webhook = {
+      hasSecret: false,
+      key: 'test-webhook-key',
+      name: 'foo',
+      url: 'http://bar',
+    };
+    const onDone = jest.fn();
+
+    renderCreateWebhookForm({ onDone, webhook });
+
+    await user.click(ui.secretInputMaskButton.get());
+    expect(ui.updateButton.get()).toBeEnabled();
+
+    await user.click(ui.updateButton.get());
+    expect(onDone).toHaveBeenCalledWith({
+      name: webhook.name,
+      url: webhook.url,
+      secret: '',
+    });
+  });
+
+  it('should correctly submit update form with updated secret', async () => {
+    const user = userEvent.setup();
+    const webhook = {
+      hasSecret: false,
+      key: 'test-webhook-key',
+      name: 'foo',
+      url: 'http://bar',
+    };
+    const secret = 'test-webhook-secret';
+    const onDone = jest.fn();
+
+    renderCreateWebhookForm({ onDone, webhook });
+
+    await user.click(ui.secretInputMaskButton.get());
+    await user.type(ui.secretInput.get(), secret);
+
+    await user.click(ui.updateButton.get());
+    expect(onDone).toHaveBeenCalledWith({
+      name: webhook.name,
+      url: webhook.url,
+      secret,
+    });
+  });
+});
+
+function renderCreateWebhookForm(props = {}) {
+  return renderComponent(
+    <CreateWebhookForm onClose={jest.fn()} onDone={jest.fn(() => Promise.resolve())} {...props} />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-test.tsx
deleted file mode 100644 (file)
index c8ec8b5..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import CreateWebhookForm from '../CreateWebhookForm';
-
-const webhookWithoutSecret = { key: '1', name: 'foo', url: 'http://foo.bar' };
-const webhookWithSecret = { key: '2', name: 'bar', secret: 'sonar', url: 'http://foo.bar' };
-
-it('should render correctly when creating a new webhook', () => {
-  expect(getWrapper()).toMatchSnapshot();
-});
-
-it('should render correctly when updating a webhook without secret', () => {
-  expect(getWrapper({ webhook: webhookWithoutSecret })).toMatchSnapshot();
-});
-
-it('should render correctly when updating a webhook with a secret', () => {
-  expect(getWrapper({ webhook: webhookWithSecret })).toMatchSnapshot();
-});
-
-function getWrapper(props = {}) {
-  return shallow(
-    <CreateWebhookForm onClose={jest.fn()} onDone={jest.fn(() => Promise.resolve())} {...props} />
-  );
-}
index 7a04114d95363b6b6fde9930c89209576896add8..35f1d6da9aac2a2cde4eff4ec139c1a1e6d18ef2 100644 (file)
@@ -50,7 +50,7 @@ jest.mock('../../../../api/webhooks', () => ({
   ),
 }));
 
-const webhook = { key: '1', name: 'foo', url: 'http://foo.bar' };
+const webhook = { key: '1', name: 'foo', url: 'http://foo.bar', hasSecret: false };
 
 beforeEach(() => {
   (searchDeliveries as jest.Mock<any>).mockClear();
index 0aac1590d78eb27f8bf6b771f6977b03f91282c3..83756ec0ee74416f776cb960e12e3412fc8cf39e 100644 (file)
@@ -38,7 +38,7 @@ const delivery = {
   success: true,
 };
 
-const webhook = { key: '1', name: 'foo', url: 'http://foo.bar' };
+const webhook = { key: '1', name: 'foo', url: 'http://foo.bar', hasSecret: false };
 
 beforeEach(() => {
   (getDelivery as jest.Mock<any>).mockClear();
index 6c5bb8f0616f57218ffffb25c685a5366239e8a1..f9e3da4f89f979e8e6d84fd68ed257c2121a80a9 100644 (file)
@@ -26,6 +26,7 @@ const webhook = {
   key: '1',
   name: 'foo',
   url: 'http://foo.bar',
+  hasSecret: false,
 };
 
 const delivery = {
index 1e5361f5b462a0761f652e370858a86cae9c7ba3..7741671cd32fcdb0af53edc1100418c111095636 100644 (file)
@@ -21,10 +21,18 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import WebhookItem from '../WebhookItem';
 
-const webhook = {
+const webhookWithoutSecret = {
   key: '1',
   name: 'my webhook',
   url: 'http://webhook.target',
+  hasSecret: false,
+};
+
+const webhookWithSecret = {
+  key: '1',
+  name: 'my webhook',
+  url: 'http://webhook.target',
+  hasSecret: true,
 };
 
 it('should render correctly', () => {
@@ -33,7 +41,16 @@ it('should render correctly', () => {
       <WebhookItem
         onDelete={jest.fn(() => Promise.resolve())}
         onUpdate={jest.fn(() => Promise.resolve())}
-        webhook={webhook}
+        webhook={webhookWithoutSecret}
+      />
+    )
+  ).toMatchSnapshot();
+  expect(
+    shallow(
+      <WebhookItem
+        onDelete={jest.fn(() => Promise.resolve())}
+        onUpdate={jest.fn(() => Promise.resolve())}
+        webhook={webhookWithSecret}
       />
     )
   ).toMatchSnapshot();
index 312bb572b7d077d04a7f8dc2ed6d86a72508eecb..ea960a1e7649f7964f6937a77bcfdbb2a48b2722 100644 (file)
@@ -34,6 +34,7 @@ const webhook = {
   key: '1',
   name: 'my webhook',
   url: 'http://webhook.target',
+  hasSecret: false,
   latestDelivery,
 };
 
index 15210f6c7473366912b46e18e49f4e9b2cff7a7b..2f3bcace2190b5a63c827d024c0cf2145e96813d 100644 (file)
@@ -22,8 +22,8 @@ import * as React from 'react';
 import WebhooksList from '../WebhooksList';
 
 const webhooks = [
-  { key: '1', name: 'my webhook', url: 'http://webhook.target' },
-  { key: '2', name: 'jenkins webhook', url: 'http://jenkins.target' },
+  { key: '1', name: 'my webhook', url: 'http://webhook.target', hasSecret: false },
+  { key: '2', name: 'jenkins webhook', url: 'http://jenkins.target', hasSecret: false },
 ];
 
 it('should correctly render empty webhook list', () => {
index 7a0ea7fcc9fa0ffec60877252f1c634a96b36c17..ba0b2bb2a08f307aaf24e26cbf42fc621d5c130b 100644 (file)
@@ -59,11 +59,13 @@ exports[`should fetch webhooks and display them 1`] = `
         webhooks={
           [
             {
+              "hasSecret": false,
               "key": "1",
               "name": "foo",
               "url": "http://foo",
             },
             {
+              "hasSecret": false,
               "key": "2",
               "name": "bar",
               "url": "http://bar",
diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/CreateWebhookForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/CreateWebhookForm-test.tsx.snap
deleted file mode 100644 (file)
index e66235b..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly when creating a new webhook 1`] = `
-<ValidationModal
-  confirmButtonText="create"
-  header="webhooks.create"
-  initialValues={
-    {
-      "name": "",
-      "secret": "",
-      "url": "",
-    }
-  }
-  onClose={[MockFunction]}
-  onSubmit={[MockFunction]}
-  size="small"
-  validate={[Function]}
->
-  <Component />
-</ValidationModal>
-`;
-
-exports[`should render correctly when updating a webhook with a secret 1`] = `
-<ValidationModal
-  confirmButtonText="update_verb"
-  header="webhooks.update"
-  initialValues={
-    {
-      "name": "bar",
-      "secret": "sonar",
-      "url": "http://foo.bar",
-    }
-  }
-  onClose={[MockFunction]}
-  onSubmit={[MockFunction]}
-  size="small"
-  validate={[Function]}
->
-  <Component />
-</ValidationModal>
-`;
-
-exports[`should render correctly when updating a webhook without secret 1`] = `
-<ValidationModal
-  confirmButtonText="update_verb"
-  header="webhooks.update"
-  initialValues={
-    {
-      "name": "foo",
-      "secret": "",
-      "url": "http://foo.bar",
-    }
-  }
-  onClose={[MockFunction]}
-  onSubmit={[MockFunction]}
-  size="small"
-  validate={[Function]}
->
-  <Component />
-</ValidationModal>
-`;
index cc748f5df228071e1f64ff1ddaa29f885570b0ba..6e31377430a44242e30cb608211c74b408c95ef0 100644 (file)
@@ -15,6 +15,7 @@ exports[`should render correctly 1`] = `
     <WebhookItemLatestDelivery
       webhook={
         {
+          "hasSecret": false,
           "key": "1",
           "name": "my webhook",
           "url": "http://webhook.target",
@@ -23,13 +24,56 @@ exports[`should render correctly 1`] = `
     />
   </td>
   <td
-    className="thin nowrap text-right"
+    className="sw-text-right"
   >
     <WebhookActions
       onDelete={[MockFunction]}
       onUpdate={[MockFunction]}
       webhook={
         {
+          "hasSecret": false,
+          "key": "1",
+          "name": "my webhook",
+          "url": "http://webhook.target",
+        }
+      }
+    />
+  </td>
+</tr>
+`;
+
+exports[`should render correctly 2`] = `
+<tr>
+  <td>
+    my webhook
+  </td>
+  <td>
+    http://webhook.target
+  </td>
+  <td>
+    yes
+  </td>
+  <td>
+    <WebhookItemLatestDelivery
+      webhook={
+        {
+          "hasSecret": true,
+          "key": "1",
+          "name": "my webhook",
+          "url": "http://webhook.target",
+        }
+      }
+    />
+  </td>
+  <td
+    className="sw-text-right"
+  >
+    <WebhookActions
+      onDelete={[MockFunction]}
+      onUpdate={[MockFunction]}
+      webhook={
+        {
+          "hasSecret": true,
           "key": "1",
           "name": "my webhook",
           "url": "http://webhook.target",
index cf08f0136019706111ac7926747af992f70e0f9c..07ad28daa6937012d04e807dcb0a6996c1ccba4c 100644 (file)
@@ -24,7 +24,11 @@ exports[`should correctly render the webhooks 1`] = `
       <th>
         webhooks.last_execution
       </th>
-      <th />
+      <th
+        className="sw-text-right"
+      >
+        actions
+      </th>
     </tr>
   </thead>
   <tbody>
@@ -34,6 +38,7 @@ exports[`should correctly render the webhooks 1`] = `
       onUpdate={[MockFunction]}
       webhook={
         {
+          "hasSecret": false,
           "key": "2",
           "name": "jenkins webhook",
           "url": "http://jenkins.target",
@@ -46,6 +51,7 @@ exports[`should correctly render the webhooks 1`] = `
       onUpdate={[MockFunction]}
       webhook={
         {
+          "hasSecret": false,
           "key": "1",
           "name": "my webhook",
           "url": "http://webhook.target",
index 296eae7374739a99099d4f7bb0206c05071da2aa..0ddc6003ad9eef1176fcc352b3da7cb6d587f365 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-export interface Webhook {
+
+export interface WebhookResponse {
+  hasSecret: boolean;
   key: string;
   latestDelivery?: WebhookDelivery;
+  name: string;
+  url: string;
+}
+
+export interface WebhookBasePayload {
   name: string;
   secret?: string;
   url: string;
 }
 
+export interface WebhookCreatePayload extends WebhookBasePayload {
+  project?: string;
+}
+
+export interface WebhookUpdatePayload extends WebhookBasePayload {
+  webhook: string;
+}
+
 export interface WebhookDelivery {
   at: string;
   durationMs: number;
index bb956d29f9b3a508909efd26ddf9885b6e7f7b64..7c951bc1a31ec5600aef11995c9637744fa30125 100644 (file)
@@ -156,10 +156,8 @@ public class CreateAction implements WebhooksWsAction {
     webhookBuilder
       .setKey(dto.getUuid())
       .setName(dto.getName())
-      .setUrl(dto.getUrl());
-    if (dto.getSecret() != null) {
-      webhookBuilder.setSecret(dto.getSecret());
-    }
+      .setUrl(dto.getUrl())
+      .setHasSecret(dto.getSecret() != null);
     writeProtobuf(newBuilder().setWebhook(webhookBuilder).build(), request, response);
   }
 
@@ -174,8 +172,8 @@ public class CreateAction implements WebhooksWsAction {
   }
 
   private void checkNumberOfGlobalWebhooks(DbSession dbSession) {
-    int globalWehbooksCount = dbClient.webhookDao().selectGlobalWebhooks(dbSession).size();
-    if (globalWehbooksCount >= MAX_NUMBER_OF_WEBHOOKS) {
+    int globalWebhooksCount = dbClient.webhookDao().selectGlobalWebhooks(dbSession).size();
+    if (globalWebhooksCount >= MAX_NUMBER_OF_WEBHOOKS) {
       throw new IllegalArgumentException("Maximum number of global webhooks reached");
     }
   }
index 0f669a6fbb58a1d307fb96b5120c3cbae57f25d3..16004122c77a970cf725a9c2e9442a11ffcc8e15 100644 (file)
@@ -74,6 +74,7 @@ public class ListAction implements WebhooksWsAction {
       .setExampleValue(KEY_PROJECT_EXAMPLE_001);
 
     action.setChangelog(new Change("7.8", "Field 'secret' added to response"));
+    action.setChangelog(new Change("10.1", "Field 'secret' replaced by flag 'hasSecret' in response"));
   }
 
   @Override
@@ -113,10 +114,8 @@ public class ListAction implements WebhooksWsAction {
         responseElementBuilder
           .setKey(webhook.getUuid())
           .setName(webhook.getName())
-          .setUrl(obfuscateCredentials(webhook.getUrl()));
-        if (webhook.getSecret() != null) {
-          responseElementBuilder.setSecret(webhook.getSecret());
-        }
+          .setUrl(obfuscateCredentials(webhook.getUrl()))
+          .setHasSecret(webhook.getSecret() != null);
         addLastDelivery(responseElementBuilder, webhook, lastDeliveries);
       });
     writeProtobuf(responseBuilder.build(), request, response);
index bf2f8e1020b17417a45066d9bd9dd12cab9db3c5..63097e2347c890c9dd6e337ac2a08b1cb2a7125f 100644 (file)
@@ -31,6 +31,7 @@ import org.sonar.db.webhook.WebhookDto;
 import org.sonar.server.component.ComponentFinder;
 import org.sonar.server.user.UserSession;
 
+import static org.apache.commons.lang.StringUtils.isBlank;
 import static org.sonar.server.exceptions.NotFoundException.checkFoundWithOptional;
 import static org.sonar.server.webhook.ws.WebhooksWsParameters.KEY_PARAM;
 import static org.sonar.server.webhook.ws.WebhooksWsParameters.KEY_PARAM_MAXIMUM_LENGTH;
@@ -89,9 +90,9 @@ public class UpdateAction implements WebhooksWsAction {
 
     action.createParam(SECRET_PARAM)
       .setRequired(false)
-      .setMinimumLength(1)
       .setMaximumLength(SECRET_PARAM_MAXIMUM_LENGTH)
-      .setDescription("If provided, secret will be used as the key to generate the HMAC hex (lowercase) digest value in the 'X-Sonar-Webhook-HMAC-SHA256' header")
+      .setDescription("If provided, secret will be used as the key to generate the HMAC hex (lowercase) digest value in the 'X-Sonar-Webhook-HMAC-SHA256' header. " +
+        "If blank, any secret previously configured will be removed. If not set, the secret will remain unchanged.")
       .setExampleValue("your_secret")
       .setSince("7.8");
   }
@@ -100,7 +101,7 @@ public class UpdateAction implements WebhooksWsAction {
   public void handle(Request request, Response response) throws Exception {
     userSession.checkLoggedIn();
 
-    String webhookKey = request.param(KEY_PARAM);
+    String webhookKey = request.mandatoryParam(KEY_PARAM);
     String name = request.mandatoryParam(NAME_PARAM);
     String url = request.mandatoryParam(URL_PARAM);
     String secret = request.param(SECRET_PARAM);
@@ -132,9 +133,30 @@ public class UpdateAction implements WebhooksWsAction {
     @Nullable String projectKey, @Nullable String projectName) {
     dto
       .setName(name)
-      .setUrl(url)
-      .setSecret(secret);
+      .setUrl(url);
+    setSecret(dto, secret);
     dbClient.webhookDao().update(dbSession, dto, projectKey, projectName);
   }
 
+  /**
+   * <p>Sets the secret of the webhook. The secret is set according to the following rules:
+   * <ul>
+   *   <li>If the secret is null, it will remain unchanged.</li>
+   *   <li>If the secret is blank (""), it will be removed.</li>
+   *   <li>If the secret is not null or blank, it will be set to the provided value.</li>
+   * </ul>
+   * </p>
+   * @param dto The webhook to update. It holds the old secret value.
+   * @param secret The new secret value. It can be null or blank.
+   */
+  private static void setSecret(WebhookDto dto, @Nullable String secret) {
+    if (secret != null) {
+      if (isBlank(secret)) {
+        dto.setSecret(null);
+      } else {
+        dto.setSecret(secret);
+      }
+    }
+  }
+
 }
index 29b4ee4762ebdb0134e1ff09dfe2296ebb1172f8..9aa62a4cf75e6aca76e212eb878713754a3a84f4 100644 (file)
@@ -3,6 +3,6 @@
     "key": "uuid",
     "name": "My webhook",
     "url": "https://www.my-webhook-listener.com/sonar",
-    "secret": "your_secret"
+    "hasSecret": true
   }
 }
index 008bfd0f624de65fd25e22d437711a98697efbea..6118def59c201b8dce0148008c84466250c31a56 100644 (file)
@@ -3,13 +3,14 @@
     {
       "key": "UUID-1",
       "name": "my first webhook",
-      "url": "http://www.my-webhook-listener.com/sonarqube"
+      "url": "http://www.my-webhook-listener.com/sonarqube",
+      "hasSecret": "false"
     },
     {
       "key": "UUID-2",
       "name": "my 2nd webhook",
       "url": "https://www.my-other-webhook-listener.com/fancy-listner",
-      "secret": "your_secret"
+      "hasSecret": "true"
     }
   ]
 }
index d3c08793a78da385c87e70a1e2dd250f5fdbc88a..cc2311aa4cf52cfdcba70dbf7b6ea924295b4554 100644 (file)
@@ -111,7 +111,7 @@ public class CreateActionTest {
     assertThat(response.getWebhook().getKey()).isNotNull();
     assertThat(response.getWebhook().getName()).isEqualTo(NAME_WEBHOOK_EXAMPLE_001);
     assertThat(response.getWebhook().getUrl()).isEqualTo(URL_WEBHOOK_EXAMPLE_001);
-    assertThat(response.getWebhook().getSecret()).isEqualTo("a_secret");
+    assertThat(response.getWebhook().getHasSecret()).isTrue();
   }
 
   @Test
@@ -128,7 +128,16 @@ public class CreateActionTest {
     assertThat(response.getWebhook().getKey()).isNotNull();
     assertThat(response.getWebhook().getName()).isEqualTo(NAME_WEBHOOK_EXAMPLE_001);
     assertThat(response.getWebhook().getUrl()).isEqualTo(URL_WEBHOOK_EXAMPLE_001);
-    assertThat(response.getWebhook().getSecret()).isEqualTo("a_secret");
+    assertThat(response.getWebhook().getHasSecret()).isTrue();
+
+    assertThat(webhookDbTester.selectWebhook(response.getWebhook().getKey()))
+      .isPresent()
+      .hasValueSatisfying(reloaded -> {
+        assertThat(reloaded.getName()).isEqualTo(NAME_WEBHOOK_EXAMPLE_001);
+        assertThat(reloaded.getUrl()).isEqualTo(URL_WEBHOOK_EXAMPLE_001);
+        assertThat(reloaded.getProjectUuid()).isNull();
+        assertThat(reloaded.getSecret()).isEqualTo("a_secret");
+      });
   }
 
   @Test
@@ -144,7 +153,7 @@ public class CreateActionTest {
     assertThat(response.getWebhook().getKey()).isNotNull();
     assertThat(response.getWebhook().getName()).isEqualTo(NAME_WEBHOOK_EXAMPLE_001);
     assertThat(response.getWebhook().getUrl()).isEqualTo(URL_WEBHOOK_EXAMPLE_001);
-    assertThat(response.getWebhook().hasSecret()).isFalse();
+    assertThat(response.getWebhook().getHasSecret()).isFalse();
   }
 
   @Test
@@ -163,20 +172,20 @@ public class CreateActionTest {
     assertThat(response.getWebhook().getKey()).isNotNull();
     assertThat(response.getWebhook().getName()).isEqualTo(NAME_WEBHOOK_EXAMPLE_001);
     assertThat(response.getWebhook().getUrl()).isEqualTo(URL_WEBHOOK_EXAMPLE_001);
-    assertThat(response.getWebhook().hasSecret()).isFalse();
+    assertThat(response.getWebhook().getHasSecret()).isFalse();
   }
 
   @Test
   public void fail_if_project_does_not_exist() {
     userSession.logIn();
     TestRequest request = wsActionTester.newRequest()
-      .setParam(PROJECT_KEY_PARAM, "inexistent-project-uuid")
+      .setParam(PROJECT_KEY_PARAM, "nonexistent-project-uuid")
       .setParam(NAME_PARAM, NAME_WEBHOOK_EXAMPLE_001)
       .setParam(URL_PARAM, URL_WEBHOOK_EXAMPLE_001);
 
     assertThatThrownBy(request::execute)
       .isInstanceOf(NotFoundException.class)
-      .hasMessage("Project 'inexistent-project-uuid' not found");
+      .hasMessage("Project 'nonexistent-project-uuid' not found");
   }
 
   @Test
@@ -285,11 +294,7 @@ public class CreateActionTest {
   }
 
   private static String generateStringWithLength(int length) {
-    StringBuilder sb = new StringBuilder(length);
-    for (int i = 0; i < length; i++) {
-      sb.append("x");
-    }
-    return sb.toString();
+    return "x".repeat(Math.max(0, length));
   }
 
 }
index 550d750588a99e9dc3f5262fe60b3f32a9d40796..59c2ea8ad16c3c038a6fced2ad88a0207896161b 100644 (file)
@@ -30,6 +30,7 @@ import org.sonar.db.DbClient;
 import org.sonar.db.DbTester;
 import org.sonar.db.component.ComponentDbTester;
 import org.sonar.db.component.ComponentDto;
+import org.sonar.db.permission.GlobalPermission;
 import org.sonar.db.project.ProjectDto;
 import org.sonar.db.webhook.WebhookDbTester;
 import org.sonar.db.webhook.WebhookDeliveryDbTester;
@@ -41,7 +42,6 @@ import org.sonar.server.exceptions.UnauthorizedException;
 import org.sonar.server.tester.UserSessionRule;
 import org.sonar.server.ws.TestRequest;
 import org.sonar.server.ws.WsActionTester;
-import org.sonarqube.ws.Webhooks;
 import org.sonarqube.ws.Webhooks.ListResponse;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -55,6 +55,8 @@ import static org.sonar.db.webhook.WebhookDeliveryTesting.newDto;
 import static org.sonar.db.webhook.WebhookTesting.newGlobalWebhook;
 import static org.sonar.server.tester.UserSessionRule.standalone;
 import static org.sonar.server.webhook.ws.WebhooksWsParameters.PROJECT_KEY_PARAM;
+import static org.sonarqube.ws.Webhooks.LatestDelivery;
+import static org.sonarqube.ws.Webhooks.ListResponseElement;
 
 public class ListActionTest {
 
@@ -92,7 +94,7 @@ public class ListActionTest {
       .extracting(Param::key, Param::isRequired)
       .containsExactlyInAnyOrder(
         tuple("project", false));
-    assertThat(action.changelog()).hasSize(1);
+    assertThat(action.changelog()).hasSize(2);
   }
 
   @Test
@@ -109,18 +111,18 @@ public class ListActionTest {
 
     ListResponse response = wsActionTester.newRequest().executeProtobuf(ListResponse.class);
 
-    List<Webhooks.ListResponseElement> elements = response.getWebhooksList();
+    List<ListResponseElement> elements = response.getWebhooksList();
     assertThat(elements).hasSize(2);
 
-    assertThat(elements.get(0)).extracting(Webhooks.ListResponseElement::getKey).isEqualTo(webhook1.getUuid());
-    assertThat(elements.get(0)).extracting(Webhooks.ListResponseElement::getName).isEqualTo("aaa");
+    assertThat(elements.get(0)).extracting(ListResponseElement::getKey).isEqualTo(webhook1.getUuid());
+    assertThat(elements.get(0)).extracting(ListResponseElement::getName).isEqualTo("aaa");
     assertThat(elements.get(0).getLatestDelivery()).isNotNull();
-    assertThat(elements.get(0).getLatestDelivery()).extracting(Webhooks.LatestDelivery::getId).isEqualTo("WH1-DELIVERY-2-UUID");
+    assertThat(elements.get(0).getLatestDelivery()).extracting(LatestDelivery::getId).isEqualTo("WH1-DELIVERY-2-UUID");
 
-    assertThat(elements.get(1)).extracting(Webhooks.ListResponseElement::getKey).isEqualTo(webhook2.getUuid());
-    assertThat(elements.get(1)).extracting(Webhooks.ListResponseElement::getName).isEqualTo("bbb");
+    assertThat(elements.get(1)).extracting(ListResponseElement::getKey).isEqualTo(webhook2.getUuid());
+    assertThat(elements.get(1)).extracting(ListResponseElement::getName).isEqualTo("bbb");
     assertThat(elements.get(1).getLatestDelivery()).isNotNull();
-    assertThat(elements.get(1).getLatestDelivery()).extracting(Webhooks.LatestDelivery::getId).isEqualTo("WH2-DELIVERY-2-UUID");
+    assertThat(elements.get(1).getLatestDelivery()).extracting(LatestDelivery::getId).isEqualTo("WH2-DELIVERY-2-UUID");
   }
 
   @Test
@@ -132,15 +134,15 @@ public class ListActionTest {
 
     ListResponse response = wsActionTester.newRequest().executeProtobuf(ListResponse.class);
 
-    List<Webhooks.ListResponseElement> elements = response.getWebhooksList();
+    List<ListResponseElement> elements = response.getWebhooksList();
     assertThat(elements).hasSize(2);
 
-    assertThat(elements.get(0)).extracting(Webhooks.ListResponseElement::getKey).isEqualTo(webhook1.getUuid());
-    assertThat(elements.get(0)).extracting(Webhooks.ListResponseElement::getName).isEqualTo("aaa");
+    assertThat(elements.get(0)).extracting(ListResponseElement::getKey).isEqualTo(webhook1.getUuid());
+    assertThat(elements.get(0)).extracting(ListResponseElement::getName).isEqualTo("aaa");
     assertThat(elements.get(0).hasLatestDelivery()).isFalse();
 
-    assertThat(elements.get(1)).extracting(Webhooks.ListResponseElement::getKey).isEqualTo(webhook2.getUuid());
-    assertThat(elements.get(1)).extracting(Webhooks.ListResponseElement::getName).isEqualTo("bbb");
+    assertThat(elements.get(1)).extracting(ListResponseElement::getKey).isEqualTo(webhook2.getUuid());
+    assertThat(elements.get(1)).extracting(ListResponseElement::getName).isEqualTo("bbb");
     assertThat(elements.get(1).hasLatestDelivery()).isFalse();
   }
 
@@ -157,17 +159,17 @@ public class ListActionTest {
 
     ListResponse response = wsActionTester.newRequest().executeProtobuf(ListResponse.class);
 
-    List<Webhooks.ListResponseElement> elements = response.getWebhooksList();
+    List<ListResponseElement> elements = response.getWebhooksList();
     assertThat(elements)
       .hasSize(2)
-      .extracting(Webhooks.ListResponseElement::getUrl)
+      .extracting(ListResponseElement::getUrl)
       .containsOnly(expectedUrl);
   }
 
   @Test
   public void list_global_webhooks() {
     WebhookDto dto1 = webhookDbTester.insertGlobalWebhook();
-    WebhookDto dto2 = webhookDbTester.insertGlobalWebhook();
+    WebhookDto dto2 = webhookDbTester.insertGlobalWebhook().setSecret(null);
     // insert a project-specific webhook, that should not be returned when listing global webhooks
     webhookDbTester.insertWebhook(componentDbTester.insertPrivateProjectDto());
 
@@ -177,10 +179,26 @@ public class ListActionTest {
       .executeProtobuf(ListResponse.class);
 
     assertThat(response.getWebhooksList())
-      .extracting(Webhooks.ListResponseElement::getName, Webhooks.ListResponseElement::getUrl)
+      .extracting(ListResponseElement::getName, ListResponseElement::getUrl)
       .containsExactlyInAnyOrder(tuple(dto1.getName(), dto1.getUrl()),
         tuple(dto2.getName(), dto2.getUrl()));
+  }
+
+  @Test
+  public void list_webhooks_with_secret() {
+    WebhookDto withSecret = webhookDbTester.insertGlobalWebhook();
+    WebhookDto withoutSecret = newGlobalWebhook().setSecret(null);
+    webhookDbTester.insert(withoutSecret, null, null);
+
+    userSession.logIn().addPermission(GlobalPermission.ADMINISTER);
 
+    ListResponse response = wsActionTester.newRequest()
+      .executeProtobuf(ListResponse.class);
+
+    assertThat(response.getWebhooksList())
+      .extracting(ListResponseElement::getName, ListResponseElement::getUrl, ListResponseElement::getHasSecret)
+      .containsExactlyInAnyOrder(tuple(withSecret.getName(), withSecret.getUrl(), true),
+        tuple(withoutSecret.getName(), withoutSecret.getUrl(), false));
   }
 
   @Test
@@ -196,7 +214,7 @@ public class ListActionTest {
       .executeProtobuf(ListResponse.class);
 
     assertThat(response.getWebhooksList())
-      .extracting(Webhooks.ListResponseElement::getName, Webhooks.ListResponseElement::getUrl)
+      .extracting(ListResponseElement::getName, ListResponseElement::getUrl)
       .contains(tuple(dto1.getName(), dto1.getUrl()),
         tuple(dto2.getName(), dto2.getUrl()));
 
@@ -212,7 +230,7 @@ public class ListActionTest {
       .executeProtobuf(ListResponse.class);
 
     assertThat(response.getWebhooksList())
-      .extracting(Webhooks.ListResponseElement::getName, Webhooks.ListResponseElement::getUrl)
+      .extracting(ListResponseElement::getName, ListResponseElement::getUrl)
       .contains(tuple(dto1.getName(), dto1.getUrl()),
         tuple(dto2.getName(), dto2.getUrl()));
 
index a222eee8e7916b54cdc12ba99580d20411af4cea..e7956cb4c9e4bb40dd4ba65cc4ada465fcaa079a 100644 (file)
@@ -25,6 +25,7 @@ import org.junit.Test;
 import org.sonar.api.config.Configuration;
 import org.sonar.api.resources.ResourceTypes;
 import org.sonar.api.server.ws.WebService;
+import org.sonar.api.web.UserRole;
 import org.sonar.db.DbClient;
 import org.sonar.db.DbTester;
 import org.sonar.db.component.ComponentDbTester;
@@ -104,7 +105,29 @@ public class UpdateActionTest {
     assertThat(reloaded.get().getName()).isEqualTo(NAME_WEBHOOK_EXAMPLE_001);
     assertThat(reloaded.get().getUrl()).isEqualTo(URL_WEBHOOK_EXAMPLE_001);
     assertThat(reloaded.get().getProjectUuid()).isEqualTo(dto.getProjectUuid());
-    assertThat(reloaded.get().getSecret()).isNull();
+    assertThat(reloaded.get().getSecret()).isEqualTo(dto.getSecret());
+  }
+
+  @Test
+  public void update_with_empty_secrets_removes_the_secret() {
+    ProjectDto project = componentDbTester.insertPrivateProjectDto();
+    WebhookDto dto = webhookDbTester.insertWebhook(project);
+    userSession.logIn().addProjectPermission(UserRole.ADMIN, project);
+
+    TestResponse response = wsActionTester.newRequest()
+      .setParam("webhook", dto.getUuid())
+      .setParam("name", NAME_WEBHOOK_EXAMPLE_001)
+      .setParam("url", URL_WEBHOOK_EXAMPLE_001)
+      .setParam("secret", "")
+      .execute();
+
+    assertThat(response.getStatus()).isEqualTo(HTTP_NO_CONTENT);
+    Optional<WebhookDto> reloaded = webhookDbTester.selectWebhook(dto.getUuid());
+    assertThat(reloaded).isPresent();
+    assertThat(reloaded.get().getName()).isEqualTo(NAME_WEBHOOK_EXAMPLE_001);
+    assertThat(reloaded.get().getUrl()).isEqualTo(URL_WEBHOOK_EXAMPLE_001);
+    assertThat(reloaded.get().getProjectUuid()).isEqualTo(dto.getProjectUuid());
+    assertThat(reloaded.get().getSecret()).isEqualTo(null);
   }
 
   @Test
index 888dd0d93327ba324760ad8807822697a8ff6901..9f78437bb713b7d270049d136615b6a49f7e33fa 100644 (file)
@@ -4473,9 +4473,12 @@ webhooks.name.required=Name is required.
 webhooks.no_result=No webhook defined.
 webhooks.update=Update Webhook
 webhooks.secret=Secret
-webhooks.secret_header=Secret?
+webhooks.secret_header=Has secret?
 webhooks.secret.bad_format=Secret must have a maximum length of 200 characters
-webhooks.secret.description=If provided, secret will be used as the key to generate the HMAC hex (lowercase) digest value in the 'X-Sonar-Webhook-HMAC-SHA256' header
+webhooks.secret.description=If provided, secret will be used as the key to generate the HMAC hex (lowercase) digest value in the 'X-Sonar-Webhook-HMAC-SHA256' header.
+webhooks.secret.description.update=If blank, any secret previously configured will be removed. If not set, the secret will remain unchanged.
+webhooks.secret.field_mask.description=Hidden for security reasons.
+webhooks.secret.field_mask.link=Click here to update the secret
 webhooks.url=URL
 webhooks.url.bad_format=Bad format of URL.
 webhooks.url.bad_protocol=URL must start with "http://" or "https://".
index b67f893791fd9c5505e1353c2f55b0411c758511..256ef05d2a7c2a1e1b39db3972ae1887ee3cd0bc 100644 (file)
@@ -39,7 +39,9 @@ message ListResponseElement {
   optional string name = 2;
   optional string url = 3;
   optional LatestDelivery latestDelivery = 4;
-  optional string secret = 5;
+  // deprecated
+  // optional string secret = 5;
+  optional bool hasSecret = 6;
 }
 
 // GET api/webhooks/list
@@ -55,7 +57,9 @@ message CreateWsResponse {
     optional string key = 1;
     optional string name = 2;
     optional string url = 3;
-    optional string secret = 4;
+    // deprecated
+    // optional string secret = 4;
+    required bool hasSecret = 5;
   }
 }