diff options
author | Duarte Meneses <duarte.meneses@sonarsource.com> | 2021-01-28 15:09:35 -0600 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-02-08 20:07:45 +0000 |
commit | f7aefc899f9e6ed35452d4d9829c65d9026f1c26 (patch) | |
tree | dbabb0a8b15c6a0c9c139a93f7472780e2125b8d /server/sonar-alm-client/src | |
parent | ce56313561f86d0c15ca6efd0fdf70971bbdba62 (diff) | |
download | sonarqube-f7aefc899f9e6ed35452d4d9829c65d9026f1c26.tar.gz sonarqube-f7aefc899f9e6ed35452d4d9829c65d9026f1c26.zip |
SONAR-14395 Validate permissions for BitBucket PR decoration settings
Diffstat (limited to 'server/sonar-alm-client/src')
5 files changed, 563 insertions, 0 deletions
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> 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 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"); + } +} |