diff options
author | Jacek <jacek.poreda@sonarsource.com> | 2021-01-26 11:23:07 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-02-04 20:07:07 +0000 |
commit | 044247d48921aa5486c4cea1e2719207ff250fc9 (patch) | |
tree | 1e78e0602d4a1a65b5e0f10757a25aea586317b2 /server/sonar-alm-client/src | |
parent | 67ff6070437876c08adafb5ac244252d81c315e7 (diff) | |
download | sonarqube-044247d48921aa5486c4cea1e2719207ff250fc9.tar.gz sonarqube-044247d48921aa5486c4cea1e2719207ff250fc9.zip |
SONAR-14371 Move gitlab http client to CE
Diffstat (limited to 'server/sonar-alm-client/src')
10 files changed, 925 insertions, 0 deletions
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfiguration.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfiguration.java new file mode 100644 index 00000000000..f6ea6e1ca32 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfiguration.java @@ -0,0 +1,40 @@ +/* + * 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; + +import org.sonar.api.ce.ComputeEngineSide; +import org.sonar.api.server.ServerSide; + +/** + * Holds the configuration of timeouts when connecting to ALMs. + */ +@ServerSide +@ComputeEngineSide +public interface TimeoutConfiguration { + /** + * @return connect timeout in milliseconds + */ + long getConnectTimeout(); + + /** + * @return read timeout in milliseconds + */ + long getReadTimeout(); +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfigurationImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfigurationImpl.java new file mode 100644 index 00000000000..3e73abd5a8b --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfigurationImpl.java @@ -0,0 +1,64 @@ +/* + * 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; + +import java.util.OptionalLong; +import org.sonar.api.config.Configuration; +import org.sonar.api.utils.log.Loggers; + +/** + * Implementation of {@link TimeoutConfiguration} reading values from configuration properties. + */ +public class TimeoutConfigurationImpl implements TimeoutConfiguration { + private static final String CONNECT_TIMEOUT_PROPERTY = "sonar.alm.timeout.connect"; + private static final String READ_TIMEOUT_PROPERTY = "sonar.alm.timeout.read"; + + private static final long DEFAULT_TIMEOUT = 30_000; + private final Configuration configuration; + + public TimeoutConfigurationImpl(Configuration configuration) { + this.configuration = configuration; + } + + @Override + public long getConnectTimeout() { + return safelyParseLongValue(CONNECT_TIMEOUT_PROPERTY).orElse(DEFAULT_TIMEOUT); + } + + private OptionalLong safelyParseLongValue(String property) { + return configuration.get(property) + .map(value -> { + try { + return OptionalLong.of(Long.parseLong(value)); + } catch (NumberFormatException e) { + Loggers.get(TimeoutConfigurationImpl.class) + .warn("Value of property {} can not be parsed to a long: {}", property, value); + return OptionalLong.empty(); + } + }) + .orElse(OptionalLong.empty()); + + } + + @Override + public long getReadTimeout() { + return safelyParseLongValue(READ_TIMEOUT_PROPERTY).orElse(DEFAULT_TIMEOUT); + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java new file mode 100644 index 00000000000..e0d453c56e2 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java @@ -0,0 +1,183 @@ +/* + * 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.gitlab; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.sonar.alm.client.TimeoutConfiguration; +import org.sonar.api.server.ServerSide; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.sonarqube.ws.client.OkHttpClientBuilder; + +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static java.nio.charset.StandardCharsets.UTF_8; + +@ServerSide +public class GitlabHttpClient { + + private static final Logger LOG = Loggers.get(GitlabHttpClient.class); + protected static final String PRIVATE_TOKEN = "Private-Token"; + protected final OkHttpClient client; + + public GitlabHttpClient(TimeoutConfiguration timeoutConfiguration) { + client = new OkHttpClientBuilder() + .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout()) + .setReadTimeoutMs(timeoutConfiguration.getReadTimeout()) + .build(); + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, UTF_8.toString()); + } catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex.getCause()); + } + } + + protected static void checkResponseIsSuccessful(Response response) throws IOException { + checkResponseIsSuccessful(response, "GitLab Merge Request did not happen, please check your configuration"); + } + + protected static void checkResponseIsSuccessful(Response response, String errorMessage) throws IOException { + if (!response.isSuccessful()) { + String body = response.body().string(); + LOG.error(String.format("Gitlab API call to [%s] failed with %s http code. gitlab response content : [%s]", response.request().url().toString(), response.code(), body)); + if (isTokenRevoked(response, body)) { + throw new IllegalArgumentException("Your GitLab token was revoked"); + } else if (isTokenExpired(response, body)) { + throw new IllegalArgumentException("Your GitLab token is expired"); + } else if (isInsufficientScope(response, body)) { + throw new IllegalArgumentException("Your GitLab token has insufficient scope"); + } else if (response.code() == HTTP_UNAUTHORIZED) { + throw new IllegalArgumentException("Invalid personal access token"); + } else { + throw new IllegalArgumentException(errorMessage); + } + } + } + + private static boolean isTokenRevoked(Response response, String body) { + if (response.code() == HTTP_UNAUTHORIZED) { + try { + Optional<GsonError> gitlabError = GsonError.parseOne(body); + return gitlabError.map(GsonError::getErrorDescription).map(description -> description.contains("Token was revoked")).orElse(false); + } catch (JsonParseException e) { + // nothing to do + } + } + return false; + } + + private static boolean isTokenExpired(Response response, String body) { + if (response.code() == HTTP_UNAUTHORIZED) { + try { + Optional<GsonError> gitlabError = GsonError.parseOne(body); + return gitlabError.map(GsonError::getErrorDescription).map(description -> description.contains("Token is expired")).orElse(false); + } catch (JsonParseException e) { + // nothing to do + } + } + return false; + } + + private static boolean isInsufficientScope(Response response, String body) { + if (response.code() == HTTP_FORBIDDEN) { + try { + Optional<GsonError> gitlabError = GsonError.parseOne(body); + return gitlabError.map(GsonError::getError).map("insufficient_scope"::equals).orElse(false); + } catch (JsonParseException e) { + // nothing to do + } + } + return false; + } + + public Project getProject(String gitlabUrl, String pat, Long gitlabProjectId) { + String url = String.format("%s/projects/%s", gitlabUrl, gitlabProjectId); + LOG.debug(String.format("get project : [%s]", url)); + Request request = new Request.Builder() + .addHeader(PRIVATE_TOKEN, pat) + .get() + .url(url) + .build(); + + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response); + String body = response.body().string(); + LOG.trace(String.format("loading project payload result : [%s]", body)); + return new GsonBuilder().create().fromJson(body, Project.class); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Could not parse GitLab answer to retrieve a project. Got a non-json payload as result."); + } catch (IOException e) { + throw new IllegalStateException(e.getMessage(), e); + } + } + + public ProjectList searchProjects(String gitlabUrl, String personalAccessToken, @Nullable String projectName, + int pageNumber, int pageSize) { + String url = String.format("%s/projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=%s&page=%d&per_page=%d", + gitlabUrl, projectName == null ? "" : urlEncode(projectName), pageNumber, pageSize); + + LOG.debug(String.format("get projects : [%s]", url)); + Request request = new Request.Builder() + .addHeader(PRIVATE_TOKEN, personalAccessToken) + .url(url) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response, "Could not get projects from GitLab instance"); + List<Project> projectList = Project.parseJsonArray(response.body().string()); + int returnedPageNumber = parseAndGetIntegerHeader(response.header("X-Page")); + int returnedPageSize = parseAndGetIntegerHeader(response.header("X-Per-Page")); + int totalProjects = parseAndGetIntegerHeader(response.header("X-Total")); + return new ProjectList(projectList, returnedPageNumber, returnedPageSize, totalProjects); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Could not parse GitLab answer to search projects. Got a non-json payload as result."); + } catch (IOException e) { + throw new IllegalStateException(e.getMessage(), e); + } + } + + private static int parseAndGetIntegerHeader(@Nullable String header) { + if (header == null) { + throw new IllegalArgumentException("Pagination data from GitLab response is missing"); + } else { + try { + return Integer.parseInt(header); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Could not parse pagination number", e); + } + } + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonError.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonError.java new file mode 100644 index 00000000000..8f3af6690bb --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonError.java @@ -0,0 +1,62 @@ +/* + * 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.gitlab; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import java.util.Optional; + +public class GsonError { + @SerializedName("error") + private final String error; + + @SerializedName("error_description") + private final String errorDescription; + + @SerializedName("message") + private final String message; + + public GsonError() { + this("", "", ""); + } + + public GsonError(String error, String errorDescription, String message) { + this.error = error; + this.errorDescription = errorDescription; + this.message = message; + } + + public static Optional<GsonError> parseOne(String json) { + Gson gson = new Gson(); + return Optional.ofNullable(gson.fromJson(json, GsonError.class)); + } + + public String getError() { + return error; + } + + public String getErrorDescription() { + return errorDescription; + } + + public String getMessage() { + return message; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/Project.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/Project.java new file mode 100644 index 00000000000..f20c955c88d --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/Project.java @@ -0,0 +1,105 @@ +/* + * 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.gitlab; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import java.util.LinkedList; +import java.util.List; + +public class Project { + // https://docs.gitlab.com/ee/api/projects.html#get-single-project + // https://docs.gitlab.com/ee/api/projects.html#list-all-projects + @SerializedName("id") + private long id; + + @SerializedName("name") + private final String name; + + @SerializedName("name_with_namespace") + private String nameWithNamespace; + + @SerializedName("path") + private String path; + + @SerializedName("path_with_namespace") + private final String pathWithNamespace; + + @SerializedName("web_url") + private String webUrl; + + public Project(String name, String pathWithNamespace) { + this.name = name; + this.pathWithNamespace = pathWithNamespace; + } + + public Project() { + // http://stackoverflow.com/a/18645370/229031 + this(0, "", "", "", "", ""); + } + + public Project(long id, String name, String nameWithNamespace, String path, String pathWithNamespace, + String webUrl) { + this.id = id; + this.name = name; + this.nameWithNamespace = nameWithNamespace; + this.path = path; + this.pathWithNamespace = pathWithNamespace; + this.webUrl = webUrl; + } + + + public static Project parseJson(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, Project.class); + } + + public static List<Project> parseJsonArray(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, new TypeToken<LinkedList<Project>>() { + }.getType()); + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getNameWithNamespace() { + return nameWithNamespace; + } + + public String getPath() { + return path; + } + + public String getPathWithNamespace() { + return pathWithNamespace; + } + + public String getWebUrl() { + return webUrl; + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/ProjectList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/ProjectList.java new file mode 100644 index 00000000000..6548e669922 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/ProjectList.java @@ -0,0 +1,53 @@ +/* + * 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.gitlab; + +import java.util.List; + +public class ProjectList { + + private final List<Project> projects; + private final int pageNumber; + private final int pageSize; + private final int total; + + public ProjectList(List<Project> projects, int pageNumber, int pageSize, int total) { + this.projects = projects; + this.pageNumber = pageNumber; + this.pageSize = pageSize; + this.total = total; + } + + public List<Project> getProjects() { + return projects; + } + + public int getPageNumber() { + return pageNumber; + } + + public int getPageSize() { + return pageSize; + } + + public int getTotal() { + return total; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/package-info.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/package-info.java new file mode 100644 index 00000000000..54fa6284913 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ +@ParametersAreNonnullByDefault +package org.sonar.alm.client.gitlab; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/ConstantTimeoutConfiguration.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/ConstantTimeoutConfiguration.java new file mode 100644 index 00000000000..ac261ee5120 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/ConstantTimeoutConfiguration.java @@ -0,0 +1,38 @@ +/* + * 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; + +public class ConstantTimeoutConfiguration implements TimeoutConfiguration { + private final long timeout; + + public ConstantTimeoutConfiguration(long timeout) { + this.timeout = timeout; + } + + @Override + public long getConnectTimeout() { + return timeout; + } + + @Override + public long getReadTimeout() { + return timeout; + } +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/TimeoutConfigurationImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/TimeoutConfigurationImplTest.java new file mode 100644 index 00000000000..85f3f39d018 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/TimeoutConfigurationImplTest.java @@ -0,0 +1,87 @@ +/* + * 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; + +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Random; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.sonar.api.config.internal.MapSettings; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(DataProviderRunner.class) +public class TimeoutConfigurationImplTest { + private final MapSettings settings = new MapSettings(); + private final TimeoutConfigurationImpl underTest = new TimeoutConfigurationImpl(settings.asConfig()); + + @Test + public void getConnectTimeout_returns_10000_when_property_is_not_defined() { + assertThat(underTest.getConnectTimeout()).isEqualTo(30_000L); + } + + @Test + @UseDataProvider("notALongPropertyValues") + public void getConnectTimeout_returns_10000_when_property_is_not_a_long(String notALong) { + settings.setProperty("sonar.alm.timeout.connect", notALong); + + assertThat(underTest.getConnectTimeout()).isEqualTo(30_000L); + } + + @Test + public void getConnectTimeout_returns_value_of_property() { + long expected = new Random().nextInt(9_456_789); + settings.setProperty("sonar.alm.timeout.connect", expected); + + assertThat(underTest.getConnectTimeout()).isEqualTo(expected); + } + + @Test + public void getReadTimeout_returns_10000_when_property_is_not_defined() { + assertThat(underTest.getReadTimeout()).isEqualTo(30_000L); + } + + @Test + @UseDataProvider("notALongPropertyValues") + public void getReadTimeout_returns_10000_when_property_is_not_a_long(String notALong) { + settings.setProperty("sonar.alm.timeout.read", notALong); + + assertThat(underTest.getReadTimeout()).isEqualTo(30_000L); + } + + @Test + public void getReadTimeout_returns_value_of_property() { + long expected = new Random().nextInt(9_456_789); + settings.setProperty("sonar.alm.timeout.read", expected); + + assertThat(underTest.getReadTimeout()).isEqualTo(expected); + } + + @DataProvider + public static Object[][] notALongPropertyValues() { + return new Object[][] { + {"foo"}, + {""}, + {"12.5"} + }; + } +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java new file mode 100644 index 00000000000..a19ee334320 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java @@ -0,0 +1,270 @@ +/* + * 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.gitlab; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.sonar.alm.client.ConstantTimeoutConfiguration; +import org.sonar.alm.client.TimeoutConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +public class GitlabHttpClientTest { + + private final MockWebServer server = new MockWebServer(); + private GitlabHttpClient underTest; + private String gitlabUrl; + + @Before + public void prepare() throws IOException { + server.start(); + String urlWithEndingSlash = server.url("").toString(); + gitlabUrl = urlWithEndingSlash.substring(0, urlWithEndingSlash.length() - 1); + + TimeoutConfiguration timeoutConfiguration = new ConstantTimeoutConfiguration(10_000); + underTest = new GitlabHttpClient(timeoutConfiguration); + } + + @After + public void stopServer() throws IOException { + server.shutdown(); + } + + @Test + public void should_throw_IllegalArgumentException_when_token_is_revoked() { + MockResponse response = new MockResponse() + .setResponseCode(401) + .setBody("{\"error\":\"invalid_token\",\"error_description\":\"Token was revoked. You have to re-authorize from the user.\"}"); + server.enqueue(response); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Your GitLab token was revoked"); + } + + @Test + public void should_throw_IllegalArgumentException_when_token_insufficient_scope() { + MockResponse response = new MockResponse() + .setResponseCode(403) + .setBody("{\"error\":\"insufficient_scope\"," + + "\"error_description\":\"The request requires higher privileges than provided by the access token.\"," + + "\"scope\":\"api read_api\"}"); + server.enqueue(response); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Your GitLab token has insufficient scope"); + } + + @Test + public void should_throw_IllegalArgumentException_when_invalide_json_in_401_response() { + MockResponse response = new MockResponse() + .setResponseCode(401) + .setBody("error in pat"); + server.enqueue(response); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid personal access token"); + } + + @Test + public void get_project() { + MockResponse response = new MockResponse() + .setResponseCode(200) + .setBody("{\n" + + " \"id\": 12345,\n" + + " \"name\": \"SonarQube example 1\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 1\",\n" + + " \"path\": \"sonarqube-example-1\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-1\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-1\"\n" + + " }"); + server.enqueue(response); + + assertThat(underTest.getProject(gitlabUrl, "pat", 12345L)) + .extracting(Project::getId, Project::getName) + .containsExactly(12345L, "SonarQube example 1"); + } + + @Test + public void get_project_fail_if_non_json_payload() { + MockResponse response = new MockResponse() + .setResponseCode(200) + .setBody("non json payload"); + server.enqueue(response); + + String instanceUrl = gitlabUrl; + assertThatThrownBy(() -> underTest.getProject(instanceUrl, "pat", 12345L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Could not parse GitLab answer to retrieve a project. Got a non-json payload as result."); + } + + @Test + public void search_projects() throws InterruptedException { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[\n" + + " {\n" + + " \"id\": 1,\n" + + " \"name\": \"SonarQube example 1\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 1\",\n" + + " \"path\": \"sonarqube-example-1\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-1\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-1\"\n" + + " },\n" + + " {\n" + + " \"id\": 2,\n" + + " \"name\": \"SonarQube example 2\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 2\",\n" + + " \"path\": \"sonarqube-example-2\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-2\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2\"\n" + + " },\n" + + " {\n" + + " \"id\": 3,\n" + + " \"name\": \"SonarQube example 3\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 3\",\n" + + " \"path\": \"sonarqube-example-3\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-3\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-3\"\n" + + " }\n" + + "]"); + projects.addHeader("X-Page", 1); + projects.addHeader("X-Per-Page", 10); + projects.addHeader("X-Total", 3); + server.enqueue(projects); + + ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "example", 1, 10); + + assertThat(projectList.getPageNumber()).isEqualTo(1); + assertThat(projectList.getPageSize()).isEqualTo(10); + assertThat(projectList.getTotal()).isEqualTo(3); + + assertThat(projectList.getProjects()).hasSize(3); + assertThat(projectList.getProjects()).extracting( + Project::getId, Project::getName, Project::getNameWithNamespace, Project::getPath, Project::getPathWithNamespace, Project::getWebUrl).containsExactly( + tuple(1L, "SonarQube example 1", "SonarSource / SonarQube / SonarQube example 1", "sonarqube-example-1", "sonarsource/sonarqube/sonarqube-example-1", + "https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-1"), + tuple(2L, "SonarQube example 2", "SonarSource / SonarQube / SonarQube example 2", "sonarqube-example-2", "sonarsource/sonarqube/sonarqube-example-2", + "https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2"), + tuple(3L, "SonarQube example 3", "SonarSource / SonarQube / SonarQube example 3", "sonarqube-example-3", "sonarsource/sonarqube/sonarqube-example-3", + "https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-3")); + + RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS); + String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString(); + assertThat(gitlabUrlCall).isEqualTo(server.url("") + "projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=example&page=1&per_page=10"); + assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET"); + } + + @Test + public void search_projects_projectName_param_should_be_encoded() throws InterruptedException { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[]"); + projects.addHeader("X-Page", 1); + projects.addHeader("X-Per-Page", 10); + projects.addHeader("X-Total", 0); + server.enqueue(projects); + + ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "&page=<script>alert('nasty')</script>", 1, 10); + + RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS); + String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString(); + assertThat(projectList.getProjects()).isEmpty(); + assertThat(gitlabUrlCall).isEqualTo( + server.url("") + + "projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=%26page%3D%3Cscript%3Ealert%28%27nasty%27%29%3C%2Fscript%3E&page=1&per_page=10"); + assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET"); + } + + @Test + public void search_projects_projectName_param_null_should_pass_empty_string() throws InterruptedException { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[]"); + projects.addHeader("X-Page", 1); + projects.addHeader("X-Per-Page", 10); + projects.addHeader("X-Total", 0); + server.enqueue(projects); + + ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", null, 1, 10); + + RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS); + String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString(); + assertThat(projectList.getProjects()).isEmpty(); + assertThat(gitlabUrlCall).isEqualTo( + server.url("") + "projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=&page=1&per_page=10"); + assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET"); + } + + @Test + public void search_projects_fail_if_could_not_parse_pagination_number() { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[ ]"); + projects.addHeader("X-Page", "bad-page-number"); + projects.addHeader("X-Per-Page", "bad-per-page-number"); + projects.addHeader("X-Total", "bad-total-number"); + server.enqueue(projects); + + String gitlabInstanceUrl = gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabInstanceUrl, "pat", "example", 1, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Could not parse pagination number"); + } + + @Test + public void search_projects_fail_if_pagination_data_not_returned() { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[ ]"); + server.enqueue(projects); + + String gitlabInstanceUrl = gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabInstanceUrl, "pat", "example", 1, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Pagination data from GitLab response is missing"); + } + + @Test + public void throws_ISE_when_get_projects_not_http_200() { + MockResponse projects = new MockResponse() + .setResponseCode(500) + .setBody("test"); + server.enqueue(projects); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Could not get projects from GitLab instance"); + } +} |