]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14395 Validate permissions for BitBucket PR decoration settings
authorDuarte Meneses <duarte.meneses@sonarsource.com>
Thu, 28 Jan 2021 21:09:35 +0000 (15:09 -0600)
committersonartech <sonartech@sonarsource.com>
Mon, 8 Feb 2021 20:07:45 +0000 (20:07 +0000)
server/sonar-alm-client/build.gradle
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Error.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Token.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/TokenError.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClientTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java

index e33c1f3de7afefd55f444d82b26f8301f1961f08..0162f7c3d44f981baae9afc8b702c05d4000be12 100644 (file)
@@ -3,6 +3,7 @@ description = 'SonarQube :: ALM integrations :: Clients'
 dependencies {
     compile project(path: ':sonar-plugin-api', configuration: 'shadow')
     compile project(':sonar-ws')
+    compile project(':server:sonar-webserver-api')
     compile 'com.google.code.gson:gson'
     compile 'com.google.guava:guava'
     compile 'com.squareup.okhttp3:okhttp'
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClient.java
new file mode 100644 (file)
index 0000000..1102bd3
--- /dev/null
@@ -0,0 +1,249 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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.
+ */
+package org.sonar.alm.client.bitbucket.bitbucketcloud;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonParseException;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.util.Objects;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+import okhttp3.Credentials;
+import okhttp3.FormBody;
+import okhttp3.HttpUrl;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.sonar.api.internal.apachecommons.io.IOUtils;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.sonar.server.exceptions.NotFoundException;
+
+import static org.sonar.api.internal.apachecommons.lang.StringUtils.removeEnd;
+
+@ServerSide
+public class BitbucketCloudRestClient {
+  private static final Logger LOG = Loggers.get(BitbucketCloudRestClient.class);
+  private static final String GET = "GET";
+  private static final String ENDPOINT = "https://api.bitbucket.org";
+  private static final String ACCESS_TOKEN_ENDPOINT = "https://bitbucket.org/site/oauth2/access_token";
+  private static final String VERSION = "2.0";
+  private static final String UNABLE_TO_CONTACT_BBC_SERVERS = "Unable to contact Bitbucket Cloud servers";
+  protected static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
+
+  private final OkHttpClient client;
+  private final String bitbucketCloudEndpoint;
+  private final String accessTokenEndpoint;
+
+  public BitbucketCloudRestClient(OkHttpClient okHttpClient) {
+    this(okHttpClient, ENDPOINT, ACCESS_TOKEN_ENDPOINT);
+  }
+
+  protected BitbucketCloudRestClient(OkHttpClient okHttpClient, String bitbucketCloudEndpoint, String accessTokenEndpoint) {
+    this.client = okHttpClient;
+    this.bitbucketCloudEndpoint = bitbucketCloudEndpoint;
+    this.accessTokenEndpoint = accessTokenEndpoint;
+  }
+
+  /**
+   * Validate parameters provided.
+   */
+  public void validate(String clientId, String clientSecret, String workspace) {
+    String accessToken = validateAccessToken(clientId, clientSecret);
+    doGet(accessToken, buildUrl("/workspaces/" + workspace + "/permissions"), r -> null, true);
+  }
+
+  private String validateAccessToken(String clientId, String clientSecret) {
+    Response response = null;
+    try {
+      Request request = createAccessTokenRequest(clientId, clientSecret);
+      response = client.newCall(request).execute();
+      if (response.isSuccessful()) {
+        return buildGson().fromJson(response.body().charStream(), Token.class).getAccessToken();
+      }
+
+      ErrorDetails errorMsg = getTokenError(response.body());
+      if (errorMsg.parsedErrorMsg != null) {
+        switch (errorMsg.parsedErrorMsg) {
+          case "invalid_grant":
+            throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS +
+              ": Configure the OAuth consumer in the Bitbucket workspace to be a private consumer");
+          case "unauthorized_client":
+            throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS + ": Check your credentials");
+          default:
+            LOG.info("Validation failed: " + errorMsg.parsedErrorMsg);
+        }
+      } else {
+        LOG.info("Validation failed: " + errorMsg.body);
+      }
+      throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS);
+
+    } catch (IOException e) {
+      throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS, e);
+    } finally {
+      if (response != null && response.body() != null) {
+        IOUtils.closeQuietly(response);
+      }
+    }
+  }
+
+  public String createAccessToken(String clientId, String clientSecret) {
+    Request request = createAccessTokenRequest(clientId, clientSecret);
+    return doCall(request, r -> buildGson().fromJson(r.body().charStream(), Token.class), false).getAccessToken();
+  }
+
+  private Request createAccessTokenRequest(String clientId, String clientSecret) {
+    RequestBody body = new FormBody.Builder()
+      .add("grant_type", "client_credentials")
+      .build();
+    HttpUrl url = HttpUrl.parse(accessTokenEndpoint);
+    String credential = Credentials.basic(clientId, clientSecret);
+    return new Request.Builder()
+      .method("POST", body)
+      .url(url)
+      .header("Authorization", credential)
+      .build();
+  }
+
+  protected HttpUrl buildUrl(String relativeUrl) {
+    return HttpUrl.parse(removeEnd(bitbucketCloudEndpoint, "/") + "/" + VERSION + relativeUrl);
+  }
+
+  protected <G> G doGet(String accessToken, HttpUrl url, Function<Response, G> handler, boolean throwErrorDetails) {
+    Request request = prepareRequestWithAccessToken(accessToken, GET, url, null);
+    return doCall(request, handler, throwErrorDetails);
+  }
+
+  protected <G> G doGet(String accessToken, HttpUrl url, Function<Response, G> handler) {
+    Request request = prepareRequestWithAccessToken(accessToken, GET, url, null);
+    return doCall(request, handler, false);
+  }
+
+  protected void doPost(String accessToken, HttpUrl url, RequestBody body) {
+    Request request = prepareRequestWithAccessToken(accessToken, "POST", url, body);
+    doCall(request, r -> null, false);
+  }
+
+  protected void doPut(String accessToken, HttpUrl url, String json) {
+    RequestBody body = RequestBody.create(json, JSON_MEDIA_TYPE);
+    Request request = prepareRequestWithAccessToken(accessToken, "PUT", url, body);
+    doCall(request, r -> null, false);
+  }
+
+  protected void doDelete(String accessToken, HttpUrl url) {
+    Request request = prepareRequestWithAccessToken(accessToken, "DELETE", url, null);
+    doCall(request, r -> null, false);
+  }
+
+  private <G> G doCall(Request request, Function<Response, G> handler, boolean throwErrorDetails) {
+    try (Response response = client.newCall(request).execute()) {
+      if (!response.isSuccessful()) {
+        handleError(response, throwErrorDetails);
+      }
+      return handler.apply(response);
+    } catch (IOException e) {
+      throw new IllegalStateException(UNABLE_TO_CONTACT_BBC_SERVERS, e);
+    }
+  }
+
+  private static Request prepareRequestWithAccessToken(String accessToken, String method, HttpUrl url, @Nullable RequestBody body) {
+    return new Request.Builder()
+      .method(method, body)
+      .url(url)
+      .header("Authorization", "Bearer " + accessToken)
+      .build();
+  }
+
+  public static Gson buildGson() {
+    return new GsonBuilder().create();
+  }
+
+  private static ErrorDetails getTokenError(@Nullable ResponseBody body) throws IOException {
+    return getErrorDetails(body, s -> {
+      TokenError gsonError = buildGson().fromJson(s, TokenError.class);
+      if (gsonError != null && gsonError.error != null) {
+        return gsonError.error;
+      }
+      return null;
+    });
+  }
+
+  private static <T> ErrorDetails getErrorDetails(@Nullable ResponseBody body, Function<String, String> parser) throws IOException {
+    if (body == null) {
+      return new ErrorDetails("", null);
+    }
+    String bodyStr = body.string();
+    if (body.contentType() != null && Objects.equals(JSON_MEDIA_TYPE.type(), body.contentType().type())) {
+      try {
+        return new ErrorDetails(bodyStr, parser.apply(bodyStr));
+      } catch (JsonParseException e) {
+        // ignore
+      }
+    }
+    return new ErrorDetails(bodyStr, null);
+  }
+
+  private static void handleError(Response response, boolean throwErrorDetails) throws IOException {
+    int responseCode = response.code();
+    ErrorDetails error = getError(response.body());
+    if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
+      String errorMsg = error.parsedErrorMsg != null ? error.parsedErrorMsg : "";
+      if (throwErrorDetails) {
+        throw new IllegalArgumentException(errorMsg);
+      } else {
+        throw new NotFoundException(errorMsg);
+      }
+    }
+    LOG.info(UNABLE_TO_CONTACT_BBC_SERVERS + ": {} {}", responseCode, error.parsedErrorMsg != null ? error.parsedErrorMsg : error.body);
+
+    if (throwErrorDetails && error.parsedErrorMsg != null) {
+      throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS + ": " + error.parsedErrorMsg);
+    } else {
+      throw new IllegalStateException(UNABLE_TO_CONTACT_BBC_SERVERS);
+    }
+  }
+
+  private static ErrorDetails getError(@Nullable ResponseBody body) throws IOException {
+    return getErrorDetails(body, s -> {
+      Error gsonError = buildGson().fromJson(s, Error.class);
+      if (gsonError != null && gsonError.errorMsg != null && gsonError.errorMsg.message != null) {
+        return gsonError.errorMsg.message;
+      }
+      return null;
+    });
+  }
+
+  private static class ErrorDetails {
+    private String body;
+    @Nullable
+    private String parsedErrorMsg;
+
+    public ErrorDetails(String body, @Nullable String parsedErrorMsg) {
+      this.body = body;
+      this.parsedErrorMsg = parsedErrorMsg;
+    }
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Error.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Error.java
new file mode 100644 (file)
index 0000000..71bff12
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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.
+ */
+package org.sonar.alm.client.bitbucket.bitbucketcloud;
+
+import com.google.gson.annotations.SerializedName;
+
+public class Error {
+  @SerializedName("error")
+  public ErrorMsg errorMsg;
+
+  public static class ErrorMsg {
+    @SerializedName("message")
+    public String message;
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Token.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Token.java
new file mode 100644 (file)
index 0000000..2933ecf
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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.
+ */
+package org.sonar.alm.client.bitbucket.bitbucketcloud;
+
+import com.google.gson.annotations.SerializedName;
+
+public class Token {
+  @SerializedName("scope")
+  private String scopes;
+  @SerializedName("access_token")
+  private String accessToken;
+  @SerializedName("exires_in")
+  private long expiresIn;
+  @SerializedName("token_type")
+  private String tokenType;
+  @SerializedName("state")
+  private String state;
+  @SerializedName("refresh_token")
+  private String refreshToken;
+
+  public Token() {
+    // nothing to do here
+  }
+
+  public String getScopes() {
+    return scopes;
+  }
+
+  public String getAccessToken() {
+    return accessToken;
+  }
+
+  public long getExpiresIn() {
+    return expiresIn;
+  }
+
+  public String getTokenType() {
+    return tokenType;
+  }
+
+  public String getState() {
+    return state;
+  }
+
+  public String getRefreshToken() {
+    return refreshToken;
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/TokenError.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/TokenError.java
new file mode 100644 (file)
index 0000000..ba3b9be
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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.
+ */
+package org.sonar.alm.client.bitbucket.bitbucketcloud;
+
+import com.google.gson.annotations.SerializedName;
+
+public class TokenError {
+  @SerializedName("error")
+  String error;
+
+  @SerializedName("error_description")
+  String errorDescription;
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClientTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClientTest.java
new file mode 100644 (file)
index 0000000..ba3e0bd
--- /dev/null
@@ -0,0 +1,187 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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.
+ */
+package org.sonar.alm.client.bitbucket.bitbucketcloud;
+
+import java.io.IOException;
+import okhttp3.Call;
+import okhttp3.OkHttpClient;
+import okhttp3.Protocol;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import okhttp3.mockwebserver.SocketPolicy;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.sonarqube.ws.client.OkHttpClientBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient.JSON_MEDIA_TYPE;
+
+public class BitbucketCloudRestClientTest {
+  private final MockWebServer server = new MockWebServer();
+  private BitbucketCloudRestClient underTest;
+
+  @Before
+  public void prepare() throws IOException {
+    server.start();
+
+    underTest = new BitbucketCloudRestClient(new OkHttpClientBuilder().build(), server.url("/").toString(), server.url("/").toString());
+  }
+
+  @After
+  public void stopServer() throws IOException {
+    server.shutdown();
+  }
+
+  @Test
+  public void failIfUnauthorized() {
+    server.enqueue(new MockResponse().setResponseCode(401).setBody("Unauthorized"));
+
+    assertThatIllegalArgumentException()
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("Unable to contact Bitbucket Cloud servers");
+  }
+
+  @Test
+  public void validate_fails_with_IAE_if_timeout() {
+    server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
+
+    assertThatIllegalArgumentException()
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"));
+  }
+
+  @Test
+  public void validate_success() throws Exception {
+    String tokenResponse = "{\"scopes\": \"webhook pullrequest:write\", \"access_token\": \"token\", \"expires_in\": 7200, "
+      + "\"token_type\": \"bearer\", \"state\": \"client_credentials\", \"refresh_token\": \"abc\"}";
+
+    server.enqueue(new MockResponse().setBody(tokenResponse));
+    server.enqueue(new MockResponse().setBody("OK"));
+
+    underTest.validate("clientId", "clientSecret", "workspace");
+
+    RecordedRequest request = server.takeRequest();
+    assertThat(request.getPath()).isEqualTo("/");
+    assertThat(request.getHeader("Authorization")).isNotNull();
+    assertThat(request.getBody().readUtf8()).isEqualTo("grant_type=client_credentials");
+  }
+
+  @Test
+  public void validate_with_invalid_workspace() {
+    String tokenResponse = "{\"scopes\": \"webhook pullrequest:write\", \"access_token\": \"token\", \"expires_in\": 7200, "
+      + "\"token_type\": \"bearer\", \"state\": \"client_credentials\", \"refresh_token\": \"abc\"}";
+    server.enqueue(new MockResponse().setBody(tokenResponse).setResponseCode(200).setHeader("Content-Type", JSON_MEDIA_TYPE));
+    String response = "{\"type\": \"error\", \"error\": {\"message\": \"No workspace with identifier 'workspace'.\"}}";
+
+    server.enqueue(new MockResponse().setBody(response).setResponseCode(404).setHeader("Content-Type", JSON_MEDIA_TYPE));
+
+    assertThatExceptionOfType(IllegalArgumentException.class)
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("No workspace with identifier 'workspace'.");
+  }
+
+  @Test
+  public void validate_with_private_consumer() {
+    String response = "{\"error_description\": \"Cannot use client_credentials with a consumer marked as \\\"public\\\". "
+      + "Calls for auto generated consumers should use urn:bitbucket:oauth2:jwt instead.\", \"error\": \"invalid_grant\"}";
+
+    server.enqueue(new MockResponse().setBody(response).setResponseCode(400).setHeader("Content-Type", JSON_MEDIA_TYPE));
+
+    assertThatExceptionOfType(IllegalArgumentException.class)
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("Unable to contact Bitbucket Cloud servers: Configure the OAuth consumer in the Bitbucket workspace to be a private consumer");
+  }
+
+  @Test
+  public void validate_with_invalid_credentials() {
+    String response = "{\"error_description\": \"Invalid OAuth client credentials\", \"error\": \"unauthorized_client\"}";
+
+    server.enqueue(new MockResponse().setBody(response).setResponseCode(400).setHeader("Content-Type", JSON_MEDIA_TYPE));
+
+    assertThatExceptionOfType(IllegalArgumentException.class)
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("Unable to contact Bitbucket Cloud servers: Check your credentials");
+  }
+
+  @Test
+  public void validate_with_insufficient_privileges() {
+    String tokenResponse = "{\"scopes\": \"webhook pullrequest:write\", \"access_token\": \"token\", \"expires_in\": 7200, "
+      + "\"token_type\": \"bearer\", \"state\": \"client_credentials\", \"refresh_token\": \"abc\"}";
+    server.enqueue(new MockResponse().setBody(tokenResponse).setResponseCode(200).setHeader("Content-Type", JSON_MEDIA_TYPE));
+
+    String error = "{\"type\": \"error\", \"error\": {\"message\": \"Your credentials lack one or more required privilege scopes.\", \"detail\": "
+      + "{\"granted\": [\"email\"], \"required\": [\"account\"]}}}\n";
+    server.enqueue(new MockResponse().setBody(error).setResponseCode(400).setHeader("Content-Type", JSON_MEDIA_TYPE));
+
+    assertThatExceptionOfType(IllegalArgumentException.class)
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("Unable to contact Bitbucket Cloud servers: Your credentials lack one or more required privilege scopes.");
+  }
+
+  @Test
+  public void nullErrorBodyIsSupported() throws IOException {
+    OkHttpClient clientMock = mock(OkHttpClient.class);
+    Call callMock = mock(Call.class);
+
+    when(callMock.execute()).thenReturn(new Response.Builder()
+      .request(new Request.Builder().url("http://any.test").build())
+      .protocol(Protocol.HTTP_1_1)
+      .code(500)
+      .message("")
+      .build());
+    when(clientMock.newCall(any())).thenReturn(callMock);
+
+    underTest = new BitbucketCloudRestClient(clientMock);
+
+    assertThatIllegalArgumentException()
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("Unable to contact Bitbucket Cloud servers");
+  }
+
+  @Test
+  public void invalidJsonResponseBodyIsSupported() {
+    server.enqueue(new MockResponse().setResponseCode(500)
+      .setHeader("content-type", "application/json; charset=utf-8")
+      .setBody("not a JSON string"));
+
+    assertThatIllegalArgumentException()
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("Unable to contact Bitbucket Cloud servers");
+  }
+
+  @Test
+  public void responseBodyWithoutErrorFieldIsSupported() {
+    server.enqueue(new MockResponse().setResponseCode(500)
+      .setHeader("content-type", "application/json; charset=utf-8")
+      .setBody("{\"foo\": \"bar\"}"));
+
+    assertThatIllegalArgumentException()
+      .isThrownBy(() -> underTest.validate("clientId", "clientSecret", "workspace"))
+      .withMessage("Unable to contact Bitbucket Cloud servers");
+  }
+}
index add468e10657627ba1f8af5756e51e37ad4408f8..813554e28ca7849bbc4aae1c9f1d4dda1459d19c 100644 (file)
@@ -20,6 +20,7 @@
 package org.sonar.server.almsettings.ws;
 
 import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient;
 import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
 import org.sonar.alm.client.github.GithubApplicationClient;
 import org.sonar.alm.client.github.GithubApplicationClientImpl;
@@ -46,11 +47,12 @@ public class ValidateAction implements AlmSettingsWsAction {
   private final GitlabHttpClient gitlabHttpClient;
   private final GithubApplicationClient githubApplicationClient;
   private final BitbucketServerRestClient bitbucketServerRestClient;
+  private final BitbucketCloudRestClient bitbucketCloudRestClient;
 
   public ValidateAction(DbClient dbClient, UserSession userSession, AlmSettingsSupport almSettingsSupport,
     AzureDevOpsHttpClient azureDevOpsHttpClient,
     GithubApplicationClientImpl githubApplicationClient, GitlabHttpClient gitlabHttpClient,
-    BitbucketServerRestClient bitbucketServerRestClient) {
+    BitbucketServerRestClient bitbucketServerRestClient, BitbucketCloudRestClient bitbucketCloudRestClient) {
     this.dbClient = dbClient;
     this.userSession = userSession;
     this.almSettingsSupport = almSettingsSupport;
@@ -58,6 +60,7 @@ public class ValidateAction implements AlmSettingsWsAction {
     this.githubApplicationClient = githubApplicationClient;
     this.gitlabHttpClient = gitlabHttpClient;
     this.bitbucketServerRestClient = bitbucketServerRestClient;
+    this.bitbucketCloudRestClient = bitbucketCloudRestClient;
   }
 
   @Override
@@ -97,7 +100,7 @@ public class ValidateAction implements AlmSettingsWsAction {
           validateBitbucketServer(almSettingDto);
           break;
         case BITBUCKET_CLOUD:
-          // TODO implement
+          validateBitbucketCloud(almSettingDto);
           break;
         case AZURE_DEVOPS:
           validateAzure(almSettingDto);
@@ -145,4 +148,8 @@ public class ValidateAction implements AlmSettingsWsAction {
     bitbucketServerRestClient.validateToken(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
     bitbucketServerRestClient.validateReadPermission(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
   }
+
+  private void validateBitbucketCloud(AlmSettingDto almSettingDto) {
+    bitbucketCloudRestClient.validate(almSettingDto.getClientId(), almSettingDto.getClientSecret(), almSettingDto.getAppId());
+  }
 }
index 8f01e672e53209643c52c4460d29bfd1cdb9d77e..676086c4f56338120f6403920220b01c20c04794 100644 (file)
@@ -24,6 +24,7 @@ import org.junit.Test;
 import org.junit.rules.ExpectedException;
 import org.mockito.ArgumentCaptor;
 import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient;
 import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
 import org.sonar.alm.client.github.GithubApplicationClientImpl;
 import org.sonar.alm.client.github.config.GithubAppConfiguration;
@@ -62,9 +63,10 @@ public class ValidateActionTest {
   private final GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class);
   private final GithubApplicationClientImpl githubApplicationClient = mock(GithubApplicationClientImpl.class);
   private final BitbucketServerRestClient bitbucketServerRestClient = mock(BitbucketServerRestClient.class);
+  private final BitbucketCloudRestClient bitbucketCloudRestClient = mock(BitbucketCloudRestClient.class);
   private final WsActionTester ws = new WsActionTester(
     new ValidateAction(db.getDbClient(), userSession, almSettingsSupport, azureDevOpsHttpClient, githubApplicationClient, gitlabHttpClient,
-      bitbucketServerRestClient));
+      bitbucketServerRestClient, bitbucketCloudRestClient));
 
   @Test
   public void fail_when_key_does_not_match_existing_alm_setting() {
index adb64eaeea3aa5cc1ea5c9e23029ecb3abde0dd4..e84ffec6efebd36fbf6e543876fe51293811afe8 100644 (file)
@@ -20,9 +20,9 @@
 package org.sonar.server.platform.platformlevel;
 
 import java.util.List;
-
 import org.sonar.alm.client.TimeoutConfigurationImpl;
 import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient;
 import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
 import org.sonar.alm.client.github.GithubApplicationClientImpl;
 import org.sonar.alm.client.github.GithubApplicationHttpClientImpl;
@@ -502,6 +502,7 @@ public class PlatformLevel4 extends PlatformLevel {
       GithubApplicationClientImpl.class,
       GithubApplicationHttpClientImpl.class,
       BitbucketServerRestClient.class,
+      BitbucketCloudRestClient.class,
       GitlabHttpClient.class,
       AzureDevOpsHttpClient.class,
       AlmIntegrationsWSModule.class,