]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16257 Validate Bitbucket Cloud Workspace IDs
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Fri, 3 Jun 2022 10:01:13 +0000 (12:01 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 7 Jun 2022 20:03:10 +0000 (20:03 +0000)
17 files changed:
server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionForm.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionFormField.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/BitbucketCloudForm.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmBindingDefinitionForm-test.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmBindingDefinitionFormField-test.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/BitbucketCloudForm-test.tsx
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/__snapshots__/AlmBindingDefinitionFormField-test.tsx.snap
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/__snapshots__/BitbucketCloudForm-test.tsx.snap
server/sonar-web/src/main/js/components/controls/ValidationInput.tsx
server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsSupport.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/CreateBitbucketCloudAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/UpdateBitbucketCloudAction.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/CreateBitbucketCloudActionTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/UpdateBitbucketCloudActionTest.java
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 15098df3e7948626911a8e6597ee1abf374dbc05..22c0ef1be02d16655c4ae29480a0425a52ee3484 100644 (file)
@@ -43,6 +43,7 @@ import {
   GitlabBindingDefinition,
   isBitbucketCloudBindingDefinition
 } from '../../../../types/alm-settings';
+import { BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT } from '../../constants';
 import AlmBindingDefinitionFormRenderer from './AlmBindingDefinitionFormRenderer';
 
 interface Props {
@@ -224,9 +225,17 @@ export default class AlmBindingDefinitionForm extends React.PureComponent<Props,
   };
 
   canSubmit = () => {
-    const { formData, touched } = this.state;
+    const { bitbucketVariant, formData, touched } = this.state;
+    const allFieldsProvided = touched && !Object.values(formData).some(v => !v);
 
-    return touched && !Object.values(formData).some(v => !v);
+    if (
+      bitbucketVariant === AlmKeys.BitbucketCloud &&
+      isBitbucketCloudBindingDefinition(formData)
+    ) {
+      return allFieldsProvided && BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT.test(formData.workspace);
+    }
+
+    return allFieldsProvided;
   };
 
   render() {
index da9d62e4448d065629d1b83b5012d39d529872af..ade370d1e75e66f5000ba4eba116018e70863e3f 100644 (file)
@@ -21,6 +21,9 @@ import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { Link } from 'react-router';
 import { ButtonLink } from '../../../../components/controls/buttons';
+import ValidationInput, {
+  ValidationInputErrorPlacement
+} from '../../../../components/controls/ValidationInput';
 import { Alert } from '../../../../components/ui/Alert';
 import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker';
 import { translate } from '../../../../helpers/l10n';
@@ -29,8 +32,10 @@ import '../../styles.css';
 
 export interface AlmBindingDefinitionFormFieldProps<B extends AlmBindingDefinitionBase> {
   autoFocus?: boolean;
+  error?: string;
   help?: React.ReactNode;
   id: string;
+  isInvalid?: boolean;
   isTextArea?: boolean;
   maxLength?: number;
   onFieldChange: (id: keyof B, value: string) => void;
@@ -46,8 +51,10 @@ export function AlmBindingDefinitionFormField<B extends AlmBindingDefinitionBase
 ) {
   const {
     autoFocus,
+    error,
     help,
     id,
+    isInvalid = false,
     isTextArea,
     maxLength,
     optional,
@@ -94,17 +101,23 @@ export function AlmBindingDefinitionFormField<B extends AlmBindingDefinitionBase
         )}
 
         {showField && !isTextArea && (
-          <input
-            className="width-100"
-            autoFocus={autoFocus}
-            id={id}
-            maxLength={maxLength || 100}
-            name={id}
-            onChange={e => props.onFieldChange(propKey, e.currentTarget.value)}
-            size={50}
-            type="text"
-            value={value}
-          />
+          <ValidationInput
+            error={error}
+            errorPlacement={ValidationInputErrorPlacement.Bottom}
+            isValid={false}
+            isInvalid={isInvalid}>
+            <input
+              className="width-100"
+              autoFocus={autoFocus}
+              id={id}
+              maxLength={maxLength || 100}
+              name={id}
+              onChange={e => props.onFieldChange(propKey, e.currentTarget.value)}
+              size={50}
+              type="text"
+              value={value}
+            />
+          </ValidationInput>
         )}
 
         {showField && isSecret && (
index 06fa664ef85803548f4bb36336500c43609c1fd7..cdbcbfdda4b25d0c0df291ef4152f9e4472f4d87 100644 (file)
@@ -24,6 +24,7 @@ import { Alert } from '../../../../components/ui/Alert';
 import { ALM_DOCUMENTATION_PATHS } from '../../../../helpers/constants';
 import { translate } from '../../../../helpers/l10n';
 import { AlmKeys, BitbucketCloudBindingDefinition } from '../../../../types/alm-settings';
+import { BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT } from '../../constants';
 import { AlmBindingDefinitionFormField } from './AlmBindingDefinitionFormField';
 
 export interface BitbucketCloudFormProps {
@@ -33,6 +34,9 @@ export interface BitbucketCloudFormProps {
 
 export default function BitbucketCloudForm(props: BitbucketCloudFormProps) {
   const { formData } = props;
+  const workspaceIDIsInvalid = Boolean(
+    formData.workspace && !BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT.test(formData.workspace)
+  );
 
   return (
     <>
@@ -62,6 +66,12 @@ export default function BitbucketCloudForm(props: BitbucketCloudFormProps) {
           />
         }
         id="workspace.bitbucketcloud"
+        error={
+          workspaceIDIsInvalid
+            ? translate('settings.almintegration.form.workspace.bitbucketcloud.error')
+            : undefined
+        }
+        isInvalid={workspaceIDIsInvalid}
         maxLength={80}
         onFieldChange={props.onFieldChange}
         propKey="workspace"
index 62d8fa6175126df94dccbd975036a1b1cfa22de4..bf9adfa568babcec4ef201a543e4bb7e569feb4f 100644 (file)
@@ -147,7 +147,11 @@ it.each([
 );
 
 it('should call the proper api for BBC', async () => {
-  const wrapper = shallowRender({ alm: AlmKeys.BitbucketServer, bindingDefinition: undefined });
+  const wrapper = shallowRender({
+    // Reminder: due to the way the settings app works, we never pass AlmKeys.BitbucketCloud as `alm`.
+    alm: AlmKeys.BitbucketServer,
+    bindingDefinition: undefined
+  });
 
   wrapper.instance().handleBitbucketVariantChange(AlmKeys.BitbucketCloud);
 
@@ -179,7 +183,25 @@ it('should store bitbucket variant', async () => {
   });
 });
 
-it('should (dis)allow submit by validating its state', () => {
+it('should (dis)allow submit by validating its state (Bitbucket Cloud)', () => {
+  const wrapper = shallowRender({
+    // Reminder: due to the way the settings app works, we never pass AlmKeys.BitbucketCloud as `alm`.
+    alm: AlmKeys.BitbucketServer,
+    bindingDefinition: mockBitbucketCloudBindingDefinition()
+  });
+  expect(wrapper.instance().canSubmit()).toBe(false);
+
+  wrapper.setState({
+    formData: mockBitbucketCloudBindingDefinition({ workspace: 'foo/bar' }),
+    touched: true
+  });
+  expect(wrapper.instance().canSubmit()).toBe(false);
+
+  wrapper.setState({ formData: mockBitbucketCloudBindingDefinition() });
+  expect(wrapper.instance().canSubmit()).toBe(true);
+});
+
+it('should (dis)allow submit by validating its state (others)', () => {
   const wrapper = shallowRender();
   expect(wrapper.instance().canSubmit()).toBe(false);
 
index 135ff9b6e7d9344313295fb85449e676ba3c707c..b55e0bf93d2f1183c7889312c4d7ed000d77d5cd 100644 (file)
@@ -34,6 +34,9 @@ it('should render correctly', () => {
   expect(shallowRender({ optional: true })).toMatchSnapshot('optional');
   expect(shallowRender({ overwriteOnly: true })).toMatchSnapshot('secret');
   expect(shallowRender({ isSecret: true })).toMatchSnapshot('encryptable');
+  expect(shallowRender({ error: 'some error message', isInvalid: true })).toMatchSnapshot(
+    'invalid with error'
+  );
 });
 
 it('should call onFieldChange', () => {
index e3ec19a5f91a680ef61b18880b09591dadf89252..03a547717fe7670938d997091c31909ca83d5f26 100644 (file)
@@ -23,8 +23,10 @@ import { mockBitbucketCloudBindingDefinition } from '../../../../../helpers/mock
 import BitbucketCloudForm, { BitbucketCloudFormProps } from '../BitbucketCloudForm';
 
 it('should render correctly', () => {
-  const wrapper = shallowRender();
-  expect(wrapper).toMatchSnapshot();
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(
+    shallowRender({ formData: mockBitbucketCloudBindingDefinition({ workspace: 'my/workspace' }) })
+  ).toMatchSnapshot('invalid workspace ID');
 });
 
 function shallowRender(props: Partial<BitbucketCloudFormProps> = {}) {
index 2cb687fd3f5f0c4b38d432ed39e984c78b77d9a4..daf6cf0886042793a2004f8e04076f0497bb9f13 100644 (file)
@@ -18,16 +18,22 @@ exports[`should render correctly: default 1`] = `
   <div
     className="settings-definition-right big-padded-top display-flex-column"
   >
-    <input
-      className="width-100"
-      id="key"
-      maxLength={40}
-      name="key"
-      onChange={[Function]}
-      size={50}
-      type="text"
-      value="key"
-    />
+    <ValidationInput
+      errorPlacement={1}
+      isInvalid={false}
+      isValid={false}
+    >
+      <input
+        className="width-100"
+        id="key"
+        maxLength={40}
+        name="key"
+        onChange={[Function]}
+        size={50}
+        type="text"
+        value="key"
+      />
+    </ValidationInput>
   </div>
 </div>
 `;
@@ -50,16 +56,22 @@ exports[`should render correctly: encryptable 1`] = `
   <div
     className="settings-definition-right big-padded-top display-flex-column"
   >
-    <input
-      className="width-100"
-      id="key"
-      maxLength={40}
-      name="key"
-      onChange={[Function]}
-      size={50}
-      type="text"
-      value="key"
-    />
+    <ValidationInput
+      errorPlacement={1}
+      isInvalid={false}
+      isValid={false}
+    >
+      <input
+        className="width-100"
+        id="key"
+        maxLength={40}
+        name="key"
+        onChange={[Function]}
+        size={50}
+        type="text"
+        value="key"
+      />
+    </ValidationInput>
     <Alert
       className="spacer-top"
       variant="info"
@@ -89,6 +101,45 @@ exports[`should render correctly: encryptable 1`] = `
 </div>
 `;
 
+exports[`should render correctly: invalid with error 1`] = `
+<div
+  className="settings-definition"
+>
+  <div
+    className="settings-definition-left"
+  >
+    <label
+      className="h3"
+      htmlFor="key"
+    >
+      settings.almintegration.form.key
+    </label>
+    <MandatoryFieldMarker />
+  </div>
+  <div
+    className="settings-definition-right big-padded-top display-flex-column"
+  >
+    <ValidationInput
+      error="some error message"
+      errorPlacement={1}
+      isInvalid={true}
+      isValid={false}
+    >
+      <input
+        className="width-100"
+        id="key"
+        maxLength={40}
+        name="key"
+        onChange={[Function]}
+        size={50}
+        type="text"
+        value="key"
+      />
+    </ValidationInput>
+  </div>
+</div>
+`;
+
 exports[`should render correctly: optional 1`] = `
 <div
   className="settings-definition"
@@ -106,16 +157,22 @@ exports[`should render correctly: optional 1`] = `
   <div
     className="settings-definition-right big-padded-top display-flex-column"
   >
-    <input
-      className="width-100"
-      id="key"
-      maxLength={40}
-      name="key"
-      onChange={[Function]}
-      size={50}
-      type="text"
-      value="key"
-    />
+    <ValidationInput
+      errorPlacement={1}
+      isInvalid={false}
+      isValid={false}
+    >
+      <input
+        className="width-100"
+        id="key"
+        maxLength={40}
+        name="key"
+        onChange={[Function]}
+        size={50}
+        type="text"
+        value="key"
+      />
+    </ValidationInput>
   </div>
 </div>
 `;
@@ -206,16 +263,22 @@ exports[`should render correctly: with help 1`] = `
   <div
     className="settings-definition-right big-padded-top display-flex-column"
   >
-    <input
-      className="width-100"
-      id="key"
-      maxLength={40}
-      name="key"
-      onChange={[Function]}
-      size={50}
-      type="text"
-      value="key"
-    />
+    <ValidationInput
+      errorPlacement={1}
+      isInvalid={false}
+      isValid={false}
+    >
+      <input
+        className="width-100"
+        id="key"
+        maxLength={40}
+        name="key"
+        onChange={[Function]}
+        size={50}
+        type="text"
+        value="key"
+      />
+    </ValidationInput>
   </div>
 </div>
 `;
index 2bf6f6b524407657dd519213721c319beb4d75ab..64876591a6439a2709f8f5f9abcd3ac63aa9123d 100644 (file)
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render correctly 1`] = `
+exports[`should render correctly: default 1`] = `
 <Fragment>
   <AlmBindingDefinitionFormField
     autoFocus={true}
@@ -30,6 +30,7 @@ exports[`should render correctly 1`] = `
       />
     }
     id="workspace.bitbucketcloud"
+    isInvalid={false}
     maxLength={80}
     onFieldChange={[MockFunction]}
     propKey="workspace"
@@ -86,3 +87,92 @@ exports[`should render correctly 1`] = `
   />
 </Fragment>
 `;
+
+exports[`should render correctly: invalid workspace ID 1`] = `
+<Fragment>
+  <AlmBindingDefinitionFormField
+    autoFocus={true}
+    help="settings.almintegration.form.name.bitbucketcloud.help"
+    id="name.bitbucket"
+    maxLength={200}
+    onFieldChange={[MockFunction]}
+    propKey="key"
+    value="key"
+  />
+  <AlmBindingDefinitionFormField
+    error="settings.almintegration.form.workspace.bitbucketcloud.error"
+    help={
+      <FormattedMessage
+        defaultMessage="settings.almintegration.form.workspace.bitbucketcloud.help"
+        id="settings.almintegration.form.workspace.bitbucketcloud.help"
+        values={
+          Object {
+            "example": <React.Fragment>
+              https://bitbucket.org/
+              <strong>
+                {workspace}
+              </strong>
+              /{repository}
+            </React.Fragment>,
+          }
+        }
+      />
+    }
+    id="workspace.bitbucketcloud"
+    isInvalid={true}
+    maxLength={80}
+    onFieldChange={[MockFunction]}
+    propKey="workspace"
+    value="my/workspace"
+  />
+  <Alert
+    className="big-spacer-top"
+    variant="info"
+  >
+    <FormattedMessage
+      defaultMessage="settings.almintegration.bitbucketcloud.info"
+      id="settings.almintegration.bitbucketcloud.info"
+      values={
+        Object {
+          "doc_link": <Link
+            onlyActiveOnIndex={false}
+            style={Object {}}
+            target="_blank"
+            to="/documentation/analysis/bitbucket-cloud-integration/"
+          >
+            learn_more
+          </Link>,
+          "oauth": <a
+            href="https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            settings.almintegration.bitbucketcloud.oauth
+          </a>,
+          "permission": <strong>
+            Pull Requests: Read
+          </strong>,
+        }
+      }
+    />
+  </Alert>
+  <AlmBindingDefinitionFormField
+    help="settings.almintegration.form.oauth_key.bitbucketcloud.help"
+    id="client_id.bitbucketcloud"
+    maxLength={80}
+    onFieldChange={[MockFunction]}
+    propKey="clientId"
+    value="client1"
+  />
+  <AlmBindingDefinitionFormField
+    help="settings.almintegration.form.oauth_secret.bitbucketcloud.help"
+    id="client_secret.bitbucketcloud"
+    isSecret={true}
+    maxLength={160}
+    onFieldChange={[MockFunction]}
+    overwriteOnly={true}
+    propKey="clientSecret"
+    value="**clientsecret**"
+  />
+</Fragment>
+`;
index 51916d231f5a87151630b91e18ba285e9f1be93b..8c122effe95644204a3d1969935117834d1d9dc4 100644 (file)
@@ -23,39 +23,77 @@ import AlertSuccessIcon from '../icons/AlertSuccessIcon';
 import MandatoryFieldMarker from '../ui/MandatoryFieldMarker';
 import HelpTooltip from './HelpTooltip';
 
-interface Props {
+export interface ValidationInputProps {
   description?: React.ReactNode;
   children: React.ReactNode;
   className?: string;
-  error: string | undefined;
+  error?: string;
+  errorPlacement?: ValidationInputErrorPlacement;
   help?: string;
-  id: string;
+  id?: string;
   isInvalid: boolean;
   isValid: boolean;
-  label: React.ReactNode;
+  label?: React.ReactNode;
   required?: boolean;
 }
 
-export default function ValidationInput(props: Props) {
-  const hasError = props.isInvalid && props.error !== undefined;
+export enum ValidationInputErrorPlacement {
+  Right,
+  Bottom
+}
+
+export default function ValidationInput(props: ValidationInputProps) {
+  const {
+    children,
+    className,
+    description,
+    error,
+    errorPlacement = ValidationInputErrorPlacement.Right,
+    help,
+    id,
+    isInvalid,
+    isValid,
+    label,
+    required
+  } = props;
+  const hasError = isInvalid && error !== undefined;
+
+  let childrenWithStatus: React.ReactNode;
+  if (errorPlacement === ValidationInputErrorPlacement.Right) {
+    childrenWithStatus = (
+      <>
+        {children}
+        {isValid && <AlertSuccessIcon className="spacer-left text-middle" />}
+        {isInvalid && <AlertErrorIcon className="spacer-left text-middle" />}
+        {hasError && <span className="little-spacer-left text-danger text-middle">{error}</span>}
+      </>
+    );
+  } else {
+    childrenWithStatus = (
+      <>
+        {children}
+        {isValid && <AlertSuccessIcon className="spacer-left text-middle" />}
+        <div className="spacer-top">
+          {isInvalid && <AlertErrorIcon className="text-middle" />}
+          {hasError && <span className="little-spacer-left text-danger text-middle">{error}</span>}
+        </div>
+      </>
+    );
+  }
+
   return (
-    <div className={props.className}>
-      <label htmlFor={props.id}>
-        <span className="text-middle">
-          <strong>{props.label}</strong>
-          {props.required && <MandatoryFieldMarker />}
-        </span>
-        {props.help && <HelpTooltip className="spacer-left" overlay={props.help} />}
-      </label>
-      <div className="little-spacer-top spacer-bottom">
-        {props.children}
-        {props.isInvalid && <AlertErrorIcon className="spacer-left text-middle" />}
-        {hasError && (
-          <span className="little-spacer-left text-danger text-middle">{props.error}</span>
-        )}
-        {props.isValid && <AlertSuccessIcon className="spacer-left text-middle" />}
-      </div>
-      {props.description && <div className="note abs-width-400">{props.description}</div>}
+    <div className={className}>
+      {id && label && (
+        <label htmlFor={id}>
+          <span className="text-middle">
+            <strong>{label}</strong>
+            {required && <MandatoryFieldMarker />}
+          </span>
+          {help && <HelpTooltip className="spacer-left" overlay={help} />}
+        </label>
+      )}
+      <div className="little-spacer-top spacer-bottom">{childrenWithStatus}</div>
+      {description && <div className="note abs-width-400">{description}</div>}
     </div>
   );
 }
index 6d076151443a81fb85c6a8f80bef43c6739c4a1a..6fbb8f8c87a92c0a0e116a8ee0ec0ac249ee3cca 100644 (file)
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
-import ValidationInput from '../ValidationInput';
+import ValidationInput, {
+  ValidationInputErrorPlacement,
+  ValidationInputProps
+} from '../ValidationInput';
 
-it('should render', () => {
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ help: 'Help message', isValid: false })).toMatchSnapshot('with help');
   expect(
-    shallow(
-      <ValidationInput
-        description="My description"
-        error={undefined}
-        help="Help message"
-        id="field-id"
-        isInvalid={false}
-        isValid={false}
-        label="Field label"
-        required={true}>
-        <div />
-      </ValidationInput>
-    )
-  ).toMatchSnapshot();
-});
-
-it('should render with error', () => {
+    shallowRender({
+      description: <div>My description</div>,
+      error: 'Field error message',
+      isInvalid: true,
+      isValid: false,
+      required: false
+    })
+  ).toMatchSnapshot('with error');
   expect(
-    shallow(
-      <ValidationInput
-        description={<div>My description</div>}
-        error="Field error message"
-        id="field-id"
-        isInvalid={true}
-        isValid={false}
-        label="Field label">
-        <div />
-      </ValidationInput>
-    )
-  ).toMatchSnapshot();
+    shallowRender({
+      error: 'Field error message',
+      errorPlacement: ValidationInputErrorPlacement.Bottom,
+      isInvalid: true,
+      isValid: false
+    })
+  ).toMatchSnapshot('error under the input');
+  expect(shallowRender({ id: undefined, label: undefined })).toMatchSnapshot('no label');
 });
 
-it('should render when valid', () => {
-  expect(
-    shallow(
-      <ValidationInput
-        description="My description"
-        error={undefined}
-        id="field-id"
-        isInvalid={false}
-        isValid={true}
-        label="Field label"
-        required={true}>
-        <div />
-      </ValidationInput>
-    )
-  ).toMatchSnapshot();
-});
+function shallowRender(props: Partial<ValidationInputProps> = {}) {
+  return shallow<ValidationInputProps>(
+    <ValidationInput
+      description="My description"
+      error={undefined}
+      id="field-id"
+      isInvalid={false}
+      isValid={true}
+      label="Field label"
+      required={true}
+      {...props}>
+      <div />
+    </ValidationInput>
+  );
+}
index c2d68a1d2bf12b40cd10a9532c51e2722d402025..949eaf4f7a17dfacc73ce96a2e17e299971d3dc1 100644 (file)
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render 1`] = `
+exports[`should render correctly: default 1`] = `
 <div>
   <label
     htmlFor="field-id"
@@ -13,15 +13,14 @@ exports[`should render 1`] = `
       </strong>
       <MandatoryFieldMarker />
     </span>
-    <HelpTooltip
-      className="spacer-left"
-      overlay="Help message"
-    />
   </label>
   <div
     className="little-spacer-top spacer-bottom"
   >
     <div />
+    <AlertSuccessIcon
+      className="spacer-left text-middle"
+    />
   </div>
   <div
     className="note abs-width-400"
@@ -31,7 +30,7 @@ exports[`should render 1`] = `
 </div>
 `;
 
-exports[`should render when valid 1`] = `
+exports[`should render correctly: error under the input 1`] = `
 <div>
   <label
     htmlFor="field-id"
@@ -45,6 +44,33 @@ exports[`should render when valid 1`] = `
       <MandatoryFieldMarker />
     </span>
   </label>
+  <div
+    className="little-spacer-top spacer-bottom"
+  >
+    <div />
+    <div
+      className="spacer-top"
+    >
+      <AlertErrorIcon
+        className="text-middle"
+      />
+      <span
+        className="little-spacer-left text-danger text-middle"
+      >
+        Field error message
+      </span>
+    </div>
+  </div>
+  <div
+    className="note abs-width-400"
+  >
+    My description
+  </div>
+</div>
+`;
+
+exports[`should render correctly: no label 1`] = `
+<div>
   <div
     className="little-spacer-top spacer-bottom"
   >
@@ -61,7 +87,7 @@ exports[`should render when valid 1`] = `
 </div>
 `;
 
-exports[`should render with error 1`] = `
+exports[`should render correctly: with error 1`] = `
 <div>
   <label
     htmlFor="field-id"
@@ -96,3 +122,34 @@ exports[`should render with error 1`] = `
   </div>
 </div>
 `;
+
+exports[`should render correctly: with help 1`] = `
+<div>
+  <label
+    htmlFor="field-id"
+  >
+    <span
+      className="text-middle"
+    >
+      <strong>
+        Field label
+      </strong>
+      <MandatoryFieldMarker />
+    </span>
+    <HelpTooltip
+      className="spacer-left"
+      overlay="Help message"
+    />
+  </label>
+  <div
+    className="little-spacer-top spacer-bottom"
+  >
+    <div />
+  </div>
+  <div
+    className="note abs-width-400"
+  >
+    My description
+  </div>
+</div>
+`;
index b185fc563f13f91fd9b5e9e1104c84399bbfe3de..c4c82245c2bd0198c2fa2e5314bd1b6d1e09eaa6 100644 (file)
@@ -19,6 +19,7 @@
  */
 package org.sonar.server.almsettings.ws;
 
+import java.util.regex.Pattern;
 import org.sonar.api.server.ServerSide;
 import org.sonar.db.DbClient;
 import org.sonar.db.DbSession;
@@ -38,6 +39,8 @@ import static org.sonar.api.web.UserRole.ADMIN;
 @ServerSide
 public class AlmSettingsSupport {
 
+  private static final Pattern WORKSPACE_ID_PATTERN = Pattern.compile("^[a-z0-9\\-_]+$");
+
   private final DbClient dbClient;
   private final UserSession userSession;
   private final ComponentFinder componentFinder;
@@ -70,6 +73,15 @@ public class AlmSettingsSupport {
     }
   }
 
+  public void checkBitbucketCloudWorkspaceIDFormat(String workspaceId) {
+    if (!WORKSPACE_ID_PATTERN.matcher(workspaceId).matches()) {
+      throw BadRequestException.create(String.format(
+        "Workspace ID '%s' has an incorrect format. Should only contain lowercase letters, numbers, dashes, and underscores.",
+        workspaceId
+      ));
+    }
+  }
+
   public ProjectDto getProjectAsAdmin(DbSession dbSession, String projectKey) {
     return getProject(dbSession, projectKey, ADMIN);
   }
index 7797287f2d8d4161c477c782142dd9340c04b27c..bf69d623f15fd3fda2f2e77bc8690b8e44020600 100644 (file)
@@ -91,6 +91,7 @@ public class CreateBitbucketCloudAction implements AlmSettingsWsAction {
       almSettingsSupport.checkAlmMultipleFeatureEnabled(BITBUCKET);
       almSettingsSupport.checkAlmMultipleFeatureEnabled(BITBUCKET_CLOUD);
       almSettingsSupport.checkAlmSettingDoesNotAlreadyExist(dbSession, key);
+      almSettingsSupport.checkBitbucketCloudWorkspaceIDFormat(workspace);
       dbClient.almSettingDao().insert(dbSession, new AlmSettingDto()
         .setAlm(BITBUCKET_CLOUD)
         .setKey(key)
index a9d528e0e510ea3af4db9662f75352eeb24caeed..cc09a39028b3726d3ff5c9ce8a48e24adbd61367 100644 (file)
@@ -102,6 +102,8 @@ public class UpdateBitbucketCloudAction implements AlmSettingsWsAction {
         almSettingDto.setClientSecret(clientSecret);
       }
 
+      almSettingsSupport.checkBitbucketCloudWorkspaceIDFormat(workspace);
+
       dbClient.almSettingDao().update(dbSession, almSettingDto
         .setKey(isNotBlank(newKey) ? newKey : key)
         .setClientId(clientId)
index fe4c6de35f710f185cbd2bb89d552769aca20b45..c2a6f93e228c9caf4a4357295a8fd357918fdc3c 100644 (file)
@@ -32,9 +32,11 @@ import org.sonar.server.component.ComponentFinder;
 import org.sonar.server.exceptions.BadRequestException;
 import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
 import org.sonar.server.ws.WsActionTester;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.groups.Tuple.tuple;
 import static org.mockito.Mockito.mock;
@@ -141,6 +143,43 @@ public class CreateBitbucketCloudActionTest {
       .isInstanceOf(ForbiddenException.class);
   }
 
+  @Test
+  public void fail_when_workspace_id_format_is_incorrect() {
+    when(multipleAlmFeatureProvider.enabled()).thenReturn(false);
+    String workspace = "workspace/name";
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).setSystemAdministrator();
+
+    TestRequest request = ws.newRequest()
+      .setParam("key", "another new key")
+      .setParam("workspace", workspace)
+      .setParam("clientId", "id")
+      .setParam("clientSecret", "secret");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(BadRequestException.class)
+      .hasMessageContaining(String.format(
+        "Workspace ID '%s' has an incorrect format. Should only contain lowercase letters, numbers, dashes, and underscores.",
+        workspace
+      ));
+  }
+
+  @Test
+  public void do_not_fail_when_workspace_id_format_is_correct() {
+    when(multipleAlmFeatureProvider.enabled()).thenReturn(false);
+    String workspace = "work-space_123";
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).setSystemAdministrator();
+
+    TestRequest request = ws.newRequest()
+      .setParam("key", "yet another new key")
+      .setParam("workspace", workspace)
+      .setParam("clientId", "id")
+      .setParam("clientSecret", "secret");
+
+    assertThatNoException().isThrownBy(request::execute);
+  }
+
   @Test
   public void definition() {
     WebService.Action def = ws.getDef();
index d2fac95ca41748e3d1f56044d5f8cc80f8c3c6ee..754ce105ebbdbff2cd4c7bdf6e146c9db02cadac 100644 (file)
@@ -29,6 +29,7 @@ import org.sonar.db.alm.setting.AlmSettingDto;
 import org.sonar.db.user.UserDto;
 import org.sonar.server.almsettings.MultipleAlmFeatureProvider;
 import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.exceptions.BadRequestException;
 import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.exceptions.NotFoundException;
 import org.sonar.server.tester.UserSessionRule;
@@ -37,6 +38,7 @@ import org.sonar.server.ws.WsActionTester;
 
 import static java.lang.String.format;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.groups.Tuple.tuple;
 import static org.mockito.ArgumentMatchers.any;
@@ -188,6 +190,43 @@ public class UpdateBitbucketCloudActionTest {
       .isInstanceOf(ForbiddenException.class);
   }
 
+  @Test
+  public void fail_when_workspace_id_format_is_incorrect() {
+    String workspace = "workspace/name";
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).setSystemAdministrator();
+    AlmSettingDto almSettingDto = db.almSettings().insertBitbucketAlmSetting();
+
+    TestRequest request = ws.newRequest()
+      .setParam("key", almSettingDto.getKey())
+      .setParam("workspace", workspace)
+      .setParam("clientId", "id")
+      .setParam("clientSecret", "secret");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(BadRequestException.class)
+      .hasMessageContaining(String.format(
+        "Workspace ID '%s' has an incorrect format. Should only contain lowercase letters, numbers, dashes, and underscores.",
+        workspace
+      ));
+  }
+
+  @Test
+  public void do_not_fail_when_workspace_id_format_is_correct() {
+    String workspace = "work-space_123";
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).setSystemAdministrator();
+    AlmSettingDto almSettingDto = db.almSettings().insertBitbucketAlmSetting();
+
+    TestRequest request = ws.newRequest()
+      .setParam("key", almSettingDto.getKey())
+      .setParam("workspace", workspace)
+      .setParam("clientId", "id")
+      .setParam("clientSecret", "secret");
+
+    assertThatNoException().isThrownBy(request::execute);
+  }
+
   @Test
   public void definition() {
     WebService.Action def = ws.getDef();
index 806319439f447136cd2420b98787d58046c85b96..bc867f692ee9e295002df3461d438f105b9dbd7a 100644 (file)
@@ -1177,6 +1177,7 @@ settings.almintegration.form.name.github.help=Give your configuration a clear an
 settings.almintegration.form.name.gitlab=Configuration name
 settings.almintegration.form.name.gitlab.help=Give your configuration a clear and succinct name. This name will be used at project level to identify the correct configured GitLab instance for a project.
 settings.almintegration.form.workspace.bitbucketcloud=Workspace ID
+settings.almintegration.form.workspace.bitbucketcloud.error=Workspace ID's can only contain lowercase letters, numbers, dashes, and underscores.
 settings.almintegration.form.workspace.bitbucketcloud.help=The workspace ID is part of your bitbucket cloud URL {example}
 settings.almintegration.form.oauth_key.bitbucketcloud.help=Bitbucket automatically creates an OAuth key when you create your OAuth consumer. You can find it in your Bitbucket Cloud workspace settings under OAuth consumers.
 settings.almintegration.form.oauth_secret.bitbucketcloud.help=Bitbucket automatically creates an OAuth secret when you create your OAuth consumer. You can find it in your Bitbucket Cloud workspace settings under OAuth consumers.