From f7aefc899f9e6ed35452d4d9829c65d9026f1c26 Mon Sep 17 00:00:00 2001 From: Duarte Meneses Date: Thu, 28 Jan 2021 15:09:35 -0600 Subject: [PATCH] SONAR-14395 Validate permissions for BitBucket PR decoration settings --- server/sonar-alm-client/build.gradle | 1 + .../BitbucketCloudRestClient.java | 249 ++++++++++++++++++ .../bitbucket/bitbucketcloud/Error.java | 32 +++ .../bitbucket/bitbucketcloud/Token.java | 65 +++++ .../bitbucket/bitbucketcloud/TokenError.java | 30 +++ .../BitbucketCloudRestClientTest.java | 187 +++++++++++++ .../server/almsettings/ws/ValidateAction.java | 11 +- .../almsettings/ws/ValidateActionTest.java | 4 +- .../platformlevel/PlatformLevel4.java | 3 +- 9 files changed, 578 insertions(+), 4 deletions(-) create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClient.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Error.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Token.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/TokenError.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClientTest.java diff --git a/server/sonar-alm-client/build.gradle b/server/sonar-alm-client/build.gradle index e33c1f3de7a..0162f7c3d44 100644 --- a/server/sonar-alm-client/build.gradle +++ b/server/sonar-alm-client/build.gradle @@ -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 index 00000000000..1102bd375c7 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClient.java @@ -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 doGet(String accessToken, HttpUrl url, Function handler, boolean throwErrorDetails) { + Request request = prepareRequestWithAccessToken(accessToken, GET, url, null); + return doCall(request, handler, throwErrorDetails); + } + + protected G doGet(String accessToken, HttpUrl url, Function 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 doCall(Request request, Function 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 ErrorDetails getErrorDetails(@Nullable ResponseBody body, Function 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 index 00000000000..71bff121248 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Error.java @@ -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 index 00000000000..2933ecf9486 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/Token.java @@ -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 index 00000000000..ba3b9be307a --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/TokenError.java @@ -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 index 00000000000..ba3e0bdf8a2 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClientTest.java @@ -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"); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java index add468e1065..813554e28ca 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java @@ -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()); + } } diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java index 8f01e672e53..676086c4f56 100644 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java @@ -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() { diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index adb64eaeea3..e84ffec6efe 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -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, -- 2.39.5