aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.gradle1
-rw-r--r--server/sonar-alm-client/build.gradle2
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java15
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java39
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java44
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java9
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java104
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java112
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java23
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java85
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java32
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java106
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java81
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java49
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java48
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java45
-rw-r--r--server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java151
-rw-r--r--server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java172
-rw-r--r--server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java51
-rw-r--r--server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java186
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java1
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java3
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java145
-rw-r--r--server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java2
-rw-r--r--server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java208
-rw-r--r--server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java3
-rw-r--r--sonar-application/build.gradle2
28 files changed, 1709 insertions, 12 deletions
diff --git a/build.gradle b/build.gradle
index 4dffcea0a78..f1c933423fb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -288,6 +288,7 @@ subprojects {
entry 'jjwt-impl'
entry 'jjwt-jackson'
}
+ dependency 'com.auth0:java-jwt:3.10.3'
dependency 'io.netty:netty-all:4.1.48.Final'
dependency 'com.sun.mail:javax.mail:1.5.6'
dependency 'javax.annotation:javax.annotation-api:1.3.2'
diff --git a/server/sonar-alm-client/build.gradle b/server/sonar-alm-client/build.gradle
index f546848d630..e33c1f3de7a 100644
--- a/server/sonar-alm-client/build.gradle
+++ b/server/sonar-alm-client/build.gradle
@@ -7,6 +7,8 @@ dependencies {
compile 'com.google.guava:guava'
compile 'com.squareup.okhttp3:okhttp'
compile 'commons-codec:commons-codec'
+ compile 'com.auth0:java-jwt'
+ compile 'org.bouncycastle:bcpkix-jdk15on:1.64'
testCompile project(':sonar-plugin-api-impl')
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java
index 7a3ba3b6303..e410ac25f56 100644
--- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java
@@ -65,6 +65,21 @@ public class BitbucketServerRestClient {
.build();
}
+ public void validateUrl(String serverUrl) {
+ HttpUrl url = buildUrl(serverUrl, "/rest/api/1.0/repos");
+ doGet("", url, r -> buildGson().fromJson(r.body().charStream(), RepositoryList.class));
+ }
+
+ public void validateToken(String serverUrl, String token) {
+ HttpUrl url = buildUrl(serverUrl, "/rest/api/1.0/users");
+ doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), UserList.class));
+ }
+
+ public void validateReadPermission(String serverUrl, String personalAccessToken) {
+ HttpUrl url = buildUrl(serverUrl, "/rest/api/1.0/repos");
+ doGet(personalAccessToken, url, r -> buildGson().fromJson(r.body().charStream(), RepositoryList.class));
+ }
+
public RepositoryList getRepos(String serverUrl, String token, @Nullable String project, @Nullable String repo) {
String projectOrEmpty = Optional.ofNullable(project).orElse("");
String repoOrEmpty = Optional.ofNullable(repo).orElse("");
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java
new file mode 100644
index 00000000000..633bc0d2a32
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java
@@ -0,0 +1,39 @@
+/*
+ * 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.bitbucketserver;
+
+import com.google.gson.annotations.SerializedName;
+
+public class User {
+
+ @SerializedName("name")
+ private String name;
+
+ @SerializedName("slug")
+ private String slug;
+
+ @SerializedName("id")
+ private long id;
+
+ public User() {
+ // http://stackoverflow.com/a/18645370/229031
+ }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java
new file mode 100644
index 00000000000..df01dc613df
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java
@@ -0,0 +1,44 @@
+/*
+ * 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.bitbucketserver;
+
+import com.google.gson.annotations.SerializedName;
+import java.util.ArrayList;
+import java.util.List;
+
+public class UserList {
+
+ @SerializedName("isLastPage")
+ private final boolean isLastPage;
+
+ @SerializedName("values")
+ private final List<User> values;
+
+ public UserList() {
+ // http://stackoverflow.com/a/18645370/229031
+ this(false, new ArrayList<>());
+ }
+
+ public UserList(boolean isLastPage, List<User> values) {
+ this.isLastPage = isLastPage;
+ this.values = values;
+ }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java
index eb98cdcafbc..13260a3e18c 100644
--- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java
@@ -26,6 +26,8 @@ import java.util.Optional;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
+
+import org.sonar.alm.client.github.config.GithubAppConfiguration;
import org.sonar.alm.client.github.security.AccessToken;
import org.sonar.alm.client.github.security.UserAccessToken;
import org.sonar.api.server.ServerSide;
@@ -53,6 +55,13 @@ public interface GithubApplicationClient {
*/
Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize);
+ void checkApiEndpoint(GithubAppConfiguration githubAppConfiguration);
+
+ /**
+ * Checks if an app has all the permissions required.
+ */
+ void checkAppPermissions(GithubAppConfiguration githubAppConfiguration);
+
/**
* Returns the repository identified by the repositoryKey owned by the provided organization.
*/
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
index 361d206a6a1..3fc47352673 100644
--- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
@@ -20,32 +20,54 @@
package org.sonar.alm.client.github;
import com.google.gson.Gson;
+
import java.io.IOException;
+import java.net.URI;
import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
+
import org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse;
import org.sonar.alm.client.github.GithubBinding.GsonGithubRepository;
import org.sonar.alm.client.github.GithubBinding.GsonInstallations;
import org.sonar.alm.client.github.GithubBinding.GsonRepositorySearch;
+import org.sonar.alm.client.github.config.GithubAppConfiguration;
import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.alm.client.github.security.AppToken;
+import org.sonar.alm.client.github.security.GithubAppSecurity;
import org.sonar.alm.client.github.security.UserAccessToken;
+import org.sonar.alm.client.gitlab.GsonApp;
+import org.sonar.api.internal.apachecommons.lang.StringUtils;
import org.sonar.api.utils.log.Logger;
import org.sonar.api.utils.log.Loggers;
import static com.google.common.base.Preconditions.checkArgument;
import static java.lang.String.format;
+import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
import static java.net.HttpURLConnection.HTTP_OK;
+import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
+import static java.util.stream.Collectors.toList;
public class GithubApplicationClientImpl implements GithubApplicationClient {
private static final Logger LOG = Loggers.get(GithubApplicationClientImpl.class);
- private static final Gson GSON = new Gson();
+ protected static final Gson GSON = new Gson();
- private final GithubApplicationHttpClient appHttpClient;
+ protected static final String WRITE_PERMISSION_NAME = "write";
+ protected static final String READ_PERMISSION_NAME = "read";
+ protected static final String FAILED_TO_REQUEST_BEGIN_MSG = "Failed to request ";
- public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient) {
+ protected final GithubApplicationHttpClient appHttpClient;
+ protected final GithubAppSecurity appSecurity;
+
+ public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient, GithubAppSecurity appSecurity) {
this.appHttpClient = appHttpClient;
+ this.appSecurity = appSecurity;
}
private static void checkPageArgs(int page, int pageSize) {
@@ -53,6 +75,68 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
checkArgument(pageSize > 0 && pageSize <= 100, "'pageSize' must be a value larger than 0 and smaller or equal to 100.");
}
+
+ @Override
+ public void checkApiEndpoint(GithubAppConfiguration githubAppConfiguration) {
+ if (StringUtils.isBlank(githubAppConfiguration.getApiEndpoint())) {
+ throw new IllegalArgumentException("Missing URL");
+ }
+
+ URI apiEndpoint;
+ try {
+ apiEndpoint = URI.create(githubAppConfiguration.getApiEndpoint());
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid URL, " + e.getMessage());
+ }
+
+ if (!"http".equalsIgnoreCase(apiEndpoint.getScheme()) && !"https".equalsIgnoreCase(apiEndpoint.getScheme())) {
+ throw new IllegalArgumentException("Only http and https schemes are supported");
+ } else if (!"api.github.com".equalsIgnoreCase(apiEndpoint.getHost()) && !apiEndpoint.getPath().toLowerCase(Locale.ENGLISH).startsWith("/api/v3")) {
+ throw new IllegalArgumentException("Invalid GitHub URL");
+ }
+ }
+
+ @Override
+ public void checkAppPermissions(GithubAppConfiguration githubAppConfiguration) {
+ AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
+
+ Map<String, String> permissions = new HashMap<>();
+ permissions.put("checks", WRITE_PERMISSION_NAME);
+ permissions.put("pull_requests", WRITE_PERMISSION_NAME);
+ permissions.put("statuses", READ_PERMISSION_NAME);
+ permissions.put("metadata", READ_PERMISSION_NAME);
+
+ String endPoint = "/app";
+ GetResponse response;
+ try {
+ response = appHttpClient.get(githubAppConfiguration.getApiEndpoint(), appToken, endPoint);
+ } catch (IOException e) {
+ LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + githubAppConfiguration.getApiEndpoint() + endPoint, e);
+ throw new IllegalArgumentException("Failed to validate configuration, check URL and Private Key");
+ }
+ if (response.getCode() == HTTP_OK) {
+ Map<String, String> perms = handleResponse(response, endPoint, GsonApp.class)
+ .map(GsonApp::getPermissions)
+ .orElseThrow(() -> new IllegalArgumentException("Failed to get app permissions, unexpected response body"));
+ List<String> missingPermissions = permissions.entrySet().stream()
+ .filter(permission -> !Objects.equals(permission.getValue(), perms.get(permission.getKey())))
+ .map(Map.Entry::getKey)
+ .collect(toList());
+
+ if (!missingPermissions.isEmpty()) {
+ String message = missingPermissions.stream()
+ .map(perm -> perm + " is '" + perms.get(perm) + "', should be '" + permissions.get(perm) + "'")
+ .collect(Collectors.joining(", "));
+
+ throw new IllegalArgumentException("Missing permissions; permission granted on " + message);
+ }
+ } else if (response.getCode() == HTTP_UNAUTHORIZED || response.getCode() == HTTP_FORBIDDEN) {
+ throw new IllegalArgumentException("Authentication failed, verify the Client Id, Client Secret and Private Key fields");
+ } else {
+ throw new IllegalArgumentException("Failed to check permissions with Github, check the configuration");
+ }
+ }
+
@Override
public Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize) {
checkPageArgs(page, pageSize);
@@ -71,7 +155,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
organizations.setOrganizations(gsonInstallations.get().installations.stream()
.map(gsonInstallation -> new Organization(gsonInstallation.account.id, gsonInstallation.account.login, null, null, null, null, null,
gsonInstallation.targetType))
- .collect(Collectors.toList()));
+ .collect(toList()));
}
return organizations;
@@ -100,7 +184,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
if (gsonRepositories.get().items != null) {
repositories.setRepositories(gsonRepositories.get().items.stream()
.map(gsonRepository -> new Repository(gsonRepository.id, gsonRepository.name, gsonRepository.isPrivate, gsonRepository.fullName, gsonRepository.url))
- .collect(Collectors.toList()));
+ .collect(toList()));
}
return repositories;
@@ -161,4 +245,14 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
throw new IllegalStateException("Failed to create GitHub's user access token", e);
}
}
+
+
+ protected static <T> Optional<T> handleResponse(GithubApplicationHttpClient.Response response, String endPoint, Class<T> gsonClass) {
+ try {
+ return response.getContent().map(c -> GSON.fromJson(c, gsonClass));
+ } catch (Exception e) {
+ LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
+ return Optional.empty();
+ }
+ }
}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java
new file mode 100644
index 00000000000..c7c6bc9eb8b
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java
@@ -0,0 +1,112 @@
+/*
+ * 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.github.config;
+
+import com.google.common.base.MoreObjects;
+import java.util.regex.Pattern;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+
+import static java.lang.String.format;
+
+public class GithubAppConfiguration {
+
+ private static final Pattern TRAILING_SLASHES = Pattern.compile("/+$");
+
+ private final Long id;
+ private final String privateKey;
+ private final String apiEndpoint;
+
+ public GithubAppConfiguration(@Nullable Long id, @Nullable String privateKey, @Nullable String apiEndpoint) {
+ this.id = id;
+ this.privateKey = privateKey;
+ this.apiEndpoint = sanitizedEndPoint(apiEndpoint);
+ }
+
+ /**
+ * Check configuration is complete with {@link #isComplete()} before calling this method.
+ *
+ * @throws IllegalStateException if configuration is not complete
+ */
+ public long getId() {
+ checkConfigurationComplete();
+ return id;
+ }
+
+ public String getApiEndpoint() {
+ checkConfigurationComplete();
+ return apiEndpoint;
+ }
+
+ /**
+ * Check configuration is complete with {@link #isComplete()} before calling this method.
+ *
+ * @throws IllegalStateException if configuration is not complete
+ */
+ public String getPrivateKey() {
+ checkConfigurationComplete();
+ return privateKey;
+ }
+
+ private void checkConfigurationComplete() {
+ if (!isComplete()) {
+ throw new IllegalStateException(format("Configuration is not complete : %s", toString()));
+ }
+ }
+
+ public boolean isComplete() {
+ return id != null &&
+ privateKey != null &&
+ apiEndpoint != null;
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(GithubAppConfiguration.class)
+ .add("id", id)
+ .add("privateKey", secureToString(privateKey))
+ .add("apiEndpoint", toString(apiEndpoint))
+ .toString();
+ }
+
+ @CheckForNull
+ private static String toString(@Nullable String s) {
+ if (s == null) {
+ return null;
+ }
+ return '\'' + s + '\'';
+ }
+
+ @CheckForNull
+ private static String secureToString(@Nullable String token) {
+ if (token == null) {
+ return null;
+ }
+ return "'***(" + token.length() + ")***'";
+ }
+
+ private static String sanitizedEndPoint(@Nullable String endPoint) {
+ if (endPoint == null) {
+ return null;
+ }
+ return TRAILING_SLASHES.matcher(endPoint).replaceAll("");
+ }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java
new file mode 100644
index 00000000000..c1f97be01fd
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/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.github.config;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java
new file mode 100644
index 00000000000..d05edea299c
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java
@@ -0,0 +1,85 @@
+/*
+ * 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.github.security;
+
+import javax.annotation.concurrent.Immutable;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * JWT (Json Web Token) to authenticate API requests on behalf
+ * of the SonarCloud App.
+ *
+ * Token expires after {@link #EXPIRATION_PERIOD_IN_MINUTES} minutes.
+ *
+ * IMPORTANT
+ * Rate limit is 5'000 API requests per hour for ALL the clients
+ * of the SonarCloud App (all instances of {@link AppToken} from Compute Engines/web servers
+ * and from the other SonarSource services using the App). For example three calls with
+ * three different tokens will consume 3 hits. Remaining quota will be 4'997.
+ * When the token is expired, the rate limit is 60 calls per hour for the public IP
+ * of the machine. BE CAREFUL, THAT SHOULD NEVER OCCUR.
+ *
+ * See https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
+ */
+@Immutable
+public class AppToken implements AccessToken {
+
+ // SONARCLOUD-468 maximum allowed by GitHub is 10 minutes but we use 9 minutes just in case clocks are not synchronized
+ static final int EXPIRATION_PERIOD_IN_MINUTES = 9;
+
+ private final String jwt;
+
+ public AppToken(String jwt) {
+ this.jwt = requireNonNull(jwt, "jwt can't be null");
+ }
+
+ @Override
+ public String getValue() {
+ return jwt;
+ }
+
+ @Override
+ public String getAuthorizationHeaderPrefix() {
+ return "Bearer";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ AppToken appToken = (AppToken) o;
+ return jwt.equals(appToken.jwt);
+ }
+
+ @Override
+ public int hashCode() {
+ return jwt.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return jwt;
+ }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java
new file mode 100644
index 00000000000..a5ed35bffe3
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.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.github.security;
+
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+@ComputeEngineSide
+public interface GithubAppSecurity {
+ /**
+ * @throws IllegalArgumentException if the key is invalid
+ */
+ AppToken createAppToken(long appId, String privateKey);
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java
new file mode 100644
index 00000000000..5a6aabd481d
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java
@@ -0,0 +1,106 @@
+/*
+ * 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.github.security;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTCreator;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.RSAKeyProvider;
+import java.io.ByteArrayInputStream;
+import java.io.InputStreamReader;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.Security;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.util.io.pem.PemObject;
+import org.bouncycastle.util.io.pem.PemReader;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+public class GithubAppSecurityImpl implements GithubAppSecurity {
+
+ private final Clock clock;
+
+ public GithubAppSecurityImpl(Clock clock) {
+ this.clock = clock;
+ }
+
+ @Override
+ public AppToken createAppToken(long appId, String privateKey) {
+ Algorithm algorithm = readApplicationPrivateKey(appId, privateKey);
+ LocalDateTime now = LocalDateTime.now(clock);
+ // Expiration period is configurable and could be greater if needed.
+ // See https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
+ LocalDateTime expiresAt = now.plus(AppToken.EXPIRATION_PERIOD_IN_MINUTES, ChronoUnit.MINUTES);
+ ZoneOffset offset = clock.getZone().getRules().getOffset(now);
+ Date nowDate = Date.from(now.toInstant(offset));
+ Date expiresAtDate = Date.from(expiresAt.toInstant(offset));
+ JWTCreator.Builder builder = JWT.create()
+ .withIssuer(String.valueOf(appId))
+ .withIssuedAt(nowDate)
+ .withExpiresAt(expiresAtDate);
+ return new AppToken(builder.sign(algorithm));
+ }
+
+ private static Algorithm readApplicationPrivateKey(long appId, String encodedPrivateKey) {
+ byte[] decodedPrivateKey = encodedPrivateKey.getBytes(UTF_8);
+ try (PemReader pemReader = new PemReader(new InputStreamReader(new ByteArrayInputStream(decodedPrivateKey)))) {
+ Security.addProvider(new BouncyCastleProvider());
+
+ PemObject pemObject = pemReader.readPemObject();
+ if (pemObject == null) {
+ throw new IllegalArgumentException("Failed to decode Github Application private key");
+ }
+
+ PKCS8EncodedKeySpec keySpec1 = new PKCS8EncodedKeySpec(pemObject.getContent());
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+ PrivateKey privateKey = keyFactory.generatePrivate(keySpec1);
+ return Algorithm.RSA256(new RSAKeyProvider() {
+ @Override
+ public RSAPublicKey getPublicKeyById(String keyId) {
+ throw new UnsupportedOperationException("getPublicKeyById not implemented");
+ }
+
+ @Override
+ public RSAPrivateKey getPrivateKey() {
+ return (RSAPrivateKey) privateKey;
+ }
+
+ @Override
+ public String getPrivateKeyId() {
+ return "github_app_" + appId;
+ }
+ });
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Invalid Github Application private key", e);
+ } finally {
+ Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME);
+ }
+ }
+
+}
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
index e0d453c56e2..f621e3d5a1d 100644
--- 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
@@ -30,11 +30,13 @@ import java.util.Optional;
import javax.annotation.Nullable;
import okhttp3.OkHttpClient;
import okhttp3.Request;
+import okhttp3.RequestBody;
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.MediaTypes;
import org.sonarqube.ws.client.OkHttpClientBuilder;
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
@@ -55,6 +57,85 @@ public class GitlabHttpClient {
.build();
}
+
+ public void checkReadPermission(@Nullable String gitlabUrl, @Nullable String personalAccessToken) {
+ checkProjectAccess(gitlabUrl, personalAccessToken, "Could not validate GitLab read permission. Got an unexpected answer.");
+ }
+
+ public void checkUrl(@Nullable String gitlabUrl) {
+ checkProjectAccess(gitlabUrl, null, "Could not validate GitLab url. Got an unexpected answer.");
+ }
+
+ private void checkProjectAccess(@Nullable String gitlabUrl, @Nullable String personalAccessToken, String errorMessage) {
+ String url = String.format("%s/projects", gitlabUrl);
+
+ LOG.debug(String.format("get projects : [%s]", url));
+ Request.Builder builder = new Request.Builder()
+ .url(url)
+ .get();
+
+ if (personalAccessToken != null) {
+ builder.addHeader(PRIVATE_TOKEN, personalAccessToken);
+ }
+
+ Request request = builder.build();
+
+ try (Response response = client.newCall(request).execute()) {
+ checkResponseIsSuccessful(response, errorMessage);
+ Project.parseJsonArray(response.body().string());
+ } catch (JsonSyntaxException e) {
+ throw new IllegalArgumentException("Could not parse GitLab answer to verify read permission. Got a non-json payload as result.");
+ } catch (IOException e) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+
+ public void checkToken(String gitlabUrl, String personalAccessToken) {
+ String url = String.format("%s/user", gitlabUrl);
+
+ LOG.debug(String.format("get current user : [%s]", url));
+ Request.Builder builder = new Request.Builder()
+ .addHeader(PRIVATE_TOKEN, personalAccessToken)
+ .url(url)
+ .get();
+
+ Request request = builder.build();
+
+ String errorMessage = "Could not validate GitLab token. Got an unexpected answer.";
+ try (Response response = client.newCall(request).execute()) {
+ checkResponseIsSuccessful(response, errorMessage);
+ GsonId.parseOne(response.body().string());
+ } catch (JsonSyntaxException e) {
+ throw new IllegalArgumentException("Could not parse GitLab answer to verify token. Got a non-json payload as result.");
+ } catch (IOException e) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+ }
+
+ public void checkWritePermission(String gitlabUrl, String personalAccessToken) {
+ String url = String.format("%s/markdown", gitlabUrl);
+
+ LOG.debug(String.format("verify write permission by formating some markdown : [%s]", url));
+ Request.Builder builder = new Request.Builder()
+ .url(url)
+ .addHeader(PRIVATE_TOKEN, personalAccessToken)
+ .addHeader("Content-Type", MediaTypes.JSON)
+ .post(RequestBody.create("{\"text\":\"validating write permission\"}".getBytes(UTF_8)));
+
+ Request request = builder.build();
+
+ String errorMessage = "Could not validate GitLab write permission. Got an unexpected answer.";
+ try (Response response = client.newCall(request).execute()) {
+ checkResponseIsSuccessful(response, errorMessage);
+ GsonMarkdown.parseOne(response.body().string());
+ } catch (JsonSyntaxException e) {
+ throw new IllegalArgumentException("Could not parse GitLab answer to verify write permission. Got a non-json payload as result.");
+ } catch (IOException e) {
+ throw new IllegalArgumentException(errorMessage);
+ }
+
+ }
+
private static String urlEncode(String value) {
try {
return URLEncoder.encode(value, UTF_8.toString());
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java
new file mode 100644
index 00000000000..2befa10790e
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java
@@ -0,0 +1,49 @@
+/*
+ * 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.annotations.SerializedName;
+
+import java.util.Map;
+
+public class GsonApp {
+ @SerializedName("installations_count")
+ private long installationsCount;
+ @SerializedName("permissions")
+ private Map<String, String> permissions;
+
+ public GsonApp() {
+ // http://stackoverflow.com/a/18645370/229031
+ }
+
+ public GsonApp(long installationsCount, Map<String, String> permissions) {
+ this.installationsCount = installationsCount;
+ this.permissions = permissions;
+ }
+
+ public long getInstallationsCount() {
+ return installationsCount;
+ }
+
+ public Map<String, String> getPermissions() {
+ return permissions;
+ }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java
new file mode 100644
index 00000000000..96948306fbc
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java
@@ -0,0 +1,48 @@
+/*
+ * 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;
+
+public class GsonId {
+
+ @SerializedName("id")
+ private final long id;
+
+ public GsonId() {
+ // http://stackoverflow.com/a/18645370/229031
+ this(0);
+ }
+
+ public GsonId(long id) {
+ this.id = id;
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public static GsonId parseOne(String json) {
+ Gson gson = new Gson();
+ return gson.fromJson(json, GsonId.class);
+ }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java
new file mode 100644
index 00000000000..39eac294c1d
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java
@@ -0,0 +1,45 @@
+/*
+ * 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 javax.annotation.Nullable;
+
+public class GsonMarkdown {
+
+ @SerializedName("html")
+ private final String html;
+
+ public GsonMarkdown() {
+ // http://stackoverflow.com/a/18645370/229031
+ this(null);
+ }
+
+ public GsonMarkdown(@Nullable String html) {
+ this.html = html;
+ }
+
+ public static GsonMarkdown parseOne(String json) {
+ Gson gson = new Gson();
+ return gson.fromJson(json, GsonMarkdown.class);
+ }
+
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
index 75d800ae4d4..4cf51c3f5df 100644
--- a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
+++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
@@ -29,13 +29,20 @@ import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
+import org.sonar.alm.client.github.config.GithubAppConfiguration;
import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.alm.client.github.security.AppToken;
+import org.sonar.alm.client.github.security.GithubAppSecurity;
import org.sonar.alm.client.github.security.UserAccessToken;
import org.sonar.api.utils.log.LogTester;
import org.sonar.api.utils.log.LoggerLevel;
+import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
+import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
+import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.groups.Tuple.tuple;
import static org.mockito.ArgumentMatchers.any;
@@ -50,17 +57,143 @@ public class GithubApplicationClientImplTest {
public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
private GithubApplicationHttpClientImpl httpClient = mock(GithubApplicationHttpClientImpl.class);
+ private GithubAppSecurity appSecurity = mock(GithubAppSecurity.class);
+ private GithubAppConfiguration githubAppConfiguration = mock(GithubAppConfiguration.class);
private GithubApplicationClient underTest;
private String appUrl = "Any URL";
@Before
public void setup() {
- underTest = new GithubApplicationClientImpl(httpClient);
+ when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
+ underTest = new GithubApplicationClientImpl(httpClient, appSecurity);
logTester.clear();
}
@Test
+ @UseDataProvider("invalidApiEndpoints")
+ public void checkApiEndpoint_Invalid(String url, String expectedMessage) {
+ GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
+
+ assertThatThrownBy(() -> underTest.checkApiEndpoint(configuration))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(expectedMessage);
+ }
+
+ @DataProvider
+ public static Object[][] invalidApiEndpoints() {
+ return new Object[][] {
+ {"", "Missing URL"},
+ {"ftp://api.github.com", "Only http and https schemes are supported"},
+ {"https://github.com", "Invalid GitHub URL"}
+ };
+ }
+
+ @Test
+ @UseDataProvider("validApiEndpoints")
+ public void checkApiEndpoint(String url) {
+ GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url);
+
+ assertThatCode(() -> underTest.checkApiEndpoint(configuration)).isNull();
+ }
+
+ @DataProvider
+ public static Object[][] validApiEndpoints() {
+ return new Object[][] {
+ {"https://github.sonarsource.com/api/v3"},
+ {"https://api.github.com"},
+ {"https://github.sonarsource.com/api/v3/"},
+ {"https://api.github.com/"},
+ {"HTTPS://api.github.com/"},
+ {"HTTP://api.github.com/"},
+ {"HtTpS://github.SonarSource.com/api/v3"},
+ {"HtTpS://github.sonarsource.com/api/V3"},
+ {"HtTpS://github.sonarsource.COM/ApI/v3"}
+ };
+ }
+
+ @Test
+ public void checkAppPermissions_IOException() throws IOException {
+ AppToken appToken = mockAppToken();
+
+ when(httpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS"));
+
+ assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Failed to validate configuration, check URL and Private Key");
+ }
+
+ @Test
+ @UseDataProvider("checkAppPermissionsErrorCodes")
+ public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException {
+ AppToken appToken = mockAppToken();
+
+ when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null));
+
+ assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(expectedMessage);
+ }
+
+ @DataProvider
+ public static Object[][] checkAppPermissionsErrorCodes() {
+ return new Object[][] {
+ {HTTP_UNAUTHORIZED, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
+ {HTTP_FORBIDDEN, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"},
+ {HTTP_NOT_FOUND, "Failed to check permissions with Github, check the configuration"}
+ };
+ }
+
+ @Test
+ public void checkAppPermissions_MissingPermissions() throws IOException {
+ AppToken appToken = mockAppToken();
+
+ when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}"));
+
+ assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Failed to get app permissions, unexpected response body");
+ }
+
+ @Test
+ public void checkAppPermissions_IncorrectPermissions() throws IOException {
+ AppToken appToken = mockAppToken();
+
+ String json = "{"
+ + " \"permissions\": {\n"
+ + " \"checks\": \"read\",\n"
+ + " \"metadata\": \"read\",\n"
+ + " \"statuses\": \"read\",\n"
+ + " \"pull_requests\": \"read\"\n"
+ + " }\n"
+ + "}";
+
+ when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
+
+ assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Missing permissions; permission granted on pull_requests is 'read', should be 'write', checks is 'read', should be 'write'");
+ }
+
+ @Test
+ public void checkAppPermissions() throws IOException {
+ AppToken appToken = mockAppToken();
+
+ String json = "{"
+ + " \"permissions\": {\n"
+ + " \"checks\": \"write\",\n"
+ + " \"metadata\": \"read\",\n"
+ + " \"statuses\": \"read\",\n"
+ + " \"pull_requests\": \"write\"\n"
+ + " }\n"
+ + "}";
+
+ when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
+
+ assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull();
+ }
+
+ @Test
@UseDataProvider("githubServers")
public void createUserAccessToken_returns_empty_if_access_token_cant_be_created(String apiUrl, String appUrl) throws IOException {
when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
@@ -664,12 +797,28 @@ public class GithubApplicationClientImplTest {
.containsOnly(1296269L, "Hello-World", "octocat/Hello-World", "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World", false);
}
+ private AppToken mockAppToken() {
+ String jwt = randomAlphanumeric(5);
+ when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(new AppToken(jwt));
+ return new AppToken(jwt);
+ }
+
private static class OkGetResponse extends Response {
private OkGetResponse(String content) {
super(200, content);
}
}
+ private static class ErrorGetResponse extends Response {
+ ErrorGetResponse() {
+ super(401, null);
+ }
+
+ ErrorGetResponse(int code, String content) {
+ super(code, content);
+ }
+ }
+
private static class Response implements GithubApplicationHttpClient.GetResponse {
private final int code;
private final String content;
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java
new file mode 100644
index 00000000000..6dd3e131e31
--- /dev/null
+++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.github.config;
+
+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 java.util.stream.Stream;
+import javax.annotation.Nullable;
+
+import org.apache.commons.lang.ArrayUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@RunWith(DataProviderRunner.class)
+public class GithubAppConfigurationTest {
+ @Rule
+ public ExpectedException expectedException = ExpectedException.none();
+
+ @Test
+ @UseDataProvider("incompleteConfigurationParametersSonarQube")
+ public void isComplete_returns_false_if_configuration_is_incomplete_on_SonarQube(@Nullable Long applicationId, @Nullable String privateKey, @Nullable String apiEndpoint) {
+ GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint);
+
+ assertThat(underTest.isComplete()).isFalse();
+ }
+
+ @Test
+ @UseDataProvider("incompleteConfigurationParametersSonarQube")
+ public void getId_throws_ISE_if_config_is_incomplete(@Nullable Long applicationId, @Nullable String privateKey, @Nullable String apiEndpoint) {
+ GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint);
+
+ expectConfigurationIncompleteISE();
+
+ underTest.getId();
+ }
+
+ @Test
+ public void getId_returns_applicationId_if_configuration_is_valid() {
+ long applicationId = new Random().nextLong();
+ GithubAppConfiguration underTest = newValidConfiguration(applicationId);
+
+ assertThat(underTest.getId()).isEqualTo(applicationId);
+ }
+
+ @Test
+ @UseDataProvider("incompleteConfigurationParametersSonarQube")
+ public void getPrivateKeyFile_throws_ISE_if_config_is_incomplete(@Nullable Long applicationId, @Nullable String privateKey, @Nullable String apiEndpoint) {
+ GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint);
+
+ expectConfigurationIncompleteISE();
+
+ underTest.getPrivateKey();
+ }
+
+ @DataProvider
+ public static Object[][] incompleteConfigurationParametersSonarQube() {
+ long applicationId = new Random().nextLong();
+ String privateKey = randomAlphabetic(9);
+ String apiEndpoint = randomAlphabetic(11);
+
+ return generateNullCombination(new Object[] {
+ applicationId,
+ privateKey,
+ apiEndpoint
+ });
+ }
+
+ @Test
+ public void toString_displays_complete_configuration() {
+ long id = 34;
+ String privateKey = randomAlphabetic(3);
+ String apiEndpoint = randomAlphabetic(7);
+
+ GithubAppConfiguration underTest = new GithubAppConfiguration(id, privateKey, apiEndpoint);
+
+ assertThat(underTest)
+ .hasToString(String.format("GithubAppConfiguration{id=%s, privateKey='***(3)***', apiEndpoint='%s'}", id, apiEndpoint));
+ }
+
+ @Test
+ public void toString_displays_incomplete_configuration() {
+ GithubAppConfiguration underTest = new GithubAppConfiguration(null, null, null);
+
+ assertThat(underTest)
+ .hasToString("GithubAppConfiguration{id=null, privateKey=null, apiEndpoint=null}");
+ }
+
+ @Test
+ public void toString_displays_privateKey_as_stars() {
+ GithubAppConfiguration underTest = new GithubAppConfiguration(null, randomAlphabetic(555), null);
+
+ assertThat(underTest)
+ .hasToString(
+ "GithubAppConfiguration{id=null, privateKey='***(555)***', apiEndpoint=null}");
+ }
+
+ @Test
+ public void equals_is_not_implemented() {
+ long applicationId = new Random().nextLong();
+ String privateKey = randomAlphabetic(8);
+ String apiEndpoint = randomAlphabetic(7);
+
+ GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint);
+
+ assertThat(underTest)
+ .isEqualTo(underTest)
+ .isNotEqualTo(new GithubAppConfiguration(applicationId, privateKey, apiEndpoint));
+ }
+
+ @Test
+ public void hashcode_is_based_on_all_fields() {
+ long applicationId = new Random().nextLong();
+ String privateKey = randomAlphabetic(8);
+ String apiEndpoint = randomAlphabetic(7);
+
+ GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint);
+
+ assertThat(underTest).hasSameHashCodeAs(underTest);
+ assertThat(underTest.hashCode()).isNotEqualTo(new GithubAppConfiguration(applicationId, privateKey, apiEndpoint));
+ }
+
+ private void expectConfigurationIncompleteISE() {
+ expectedException.expect(IllegalStateException.class);
+ expectedException.expectMessage("Configuration is not complete");
+ }
+
+ private GithubAppConfiguration newValidConfiguration(long applicationId) {
+ return new GithubAppConfiguration(applicationId, randomAlphabetic(6), randomAlphabetic(6));
+ }
+
+ private static Object[][] generateNullCombination(Object[] objects) {
+ Object[][] firstPossibleValues = new Object[][] {
+ {null},
+ {objects[0]}
+ };
+ if (objects.length == 1) {
+ return firstPossibleValues;
+ }
+
+ Object[][] subCombinations = generateNullCombination(ArrayUtils.subarray(objects, 1, objects.length));
+
+ return Stream.of(subCombinations)
+ .flatMap(combination -> Stream.of(firstPossibleValues).map(firstValue -> ArrayUtils.addAll(firstValue, combination)))
+ .filter(array -> ArrayUtils.contains(array, null))
+ .toArray(Object[][]::new);
+ }
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java
new file mode 100644
index 00000000000..f9e5560520f
--- /dev/null
+++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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.github.security;
+
+import org.junit.Test;
+import org.sonar.alm.client.github.security.AppToken;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AppTokenTest {
+
+ @Test
+ public void test_value() {
+ AppToken underTest = new AppToken("foo");
+
+ assertThat(underTest.toString())
+ .isEqualTo(underTest.getValue())
+ .isEqualTo("foo");
+
+ assertThat(underTest.getAuthorizationHeaderPrefix()).isEqualTo("Bearer");
+ }
+
+ @Test
+ public void test_equals_hashCode() {
+ AppToken foo = new AppToken("foo");
+
+ assertThat(foo)
+ .isEqualTo(foo)
+ .isEqualTo(new AppToken("foo"))
+ .isNotEqualTo(new AppToken("bar"))
+ .hasSameHashCodeAs(foo)
+ .hasSameHashCodeAs(new AppToken("foo"));
+ }
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java
new file mode 100644
index 00000000000..d558e2b54d2
--- /dev/null
+++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java
@@ -0,0 +1,186 @@
+/*
+ * 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.github.security;
+
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+
+import java.io.IOException;
+import java.security.spec.InvalidKeySpecException;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Random;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.alm.client.github.config.GithubAppConfiguration;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@RunWith(DataProviderRunner.class)
+public class GithubAppSecurityImplTest {
+ private Clock clock = Clock.fixed(Instant.ofEpochSecond(132_600_888L), ZoneId.systemDefault());
+ private GithubAppSecurityImpl underTest = new GithubAppSecurityImpl(clock);
+
+ @Test
+ public void createAppToken_fails_with_IAE_if_privateKey_content_is_garbage() {
+ String garbage = randomAlphanumeric(555);
+ GithubAppConfiguration githubAppConfiguration = createAppConfigurationForPrivateKey(garbage);
+
+ assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasRootCauseMessage("Failed to decode Github Application private key");
+
+ }
+
+ @Test
+ public void createAppToken_fails_with_IAE_if_privateKey_PKCS8_content_is_missing_end_comment() {
+ String incompletePrivateKey = "-----BEGIN RSA PRIVATE KEY-----\n" +
+ "MIIEowIBAAKCAQEA6C29ZdvrwHOu7Eewv+xvUd4inCnACTzAHukHKTSY4R16+lRI\n" +
+ "YC5qZ8Xo304J7lLhN4/d4Xnof3lDXZOHthVbJKik4fOuEGbTXTIcuFs3hdJtrJsb\n" +
+ "antv8SOl5iR4fYRAf2AILMdtZI4iMSicBLIIttR+wVXo6NJYMjpj1OuAU3uN8eET\n" +
+ "Gge09oJT3QOUBem7N8uaYi/p5uAfsf2/SVNsoMPV624X4kgNcyj/TMa6BosFJ8Y3\n" +
+ "oeg0Aguk2yuHhAnixDVGoz6N7Go0QjEipVNix2JOOJwpFH4k2iZfM6n+8sJTLilq\n" +
+ "yzT53JW/XI+M5AXVj4OjBJ/2yMPi3RFMNTdgRwIDAQABAoIBACcYBIsRI7oNAIgi\n" +
+ "bh1y1y+mwpce5Inpo8PQovcKNy+4gguCg4lGZ34/sb1f64YoiGmNnOOpXj+QkIpC\n" +
+ "HBjJscYTa2fsWwPB/Jb1qCZWnZu32eW1XEFqtWeaBAYjX/JqgV2xMs8vaTkEQbeb\n" +
+ "SeH0hEkcsJcnOwdw247hjAu+96WWlyt10ZGgQaWPfXsdtelbaoaturNAVAJHdl9e\n" +
+ "TIknCIbtLlbz/FtzjtCtdeiWr8gbKdVkshGtA8SKVhXGQwDwENjUkAUtSJ0aXR1t\n" +
+ "+UjQcTISk7LiiYs0MrJ/CKoJ7mShwx7+YF3hgyqQ0qaqHwt9Yyd7wzWdCgdM5Eha\n" +
+ "ccioIskCgYEA+EDJmcM5NGu5AYpZ1ogmG6jzsefAlr2NG1PQ/U03twal/B+ygAQb\n" +
+ "5dholrq+aF+45Hrzfxije3Zrvpb08vxzKAs20lOlJsKftx2zkLR+mNvWTAORuO16\n" +
+ "lG0c0cgYAKA1ld4R8KB8NmbuNb1w4LYZuyuFIEVmm2B3ca141WNHBwMCgYEA72yK\n" +
+ "B4+xxomZn6dtbCGQZxziaI9WH/KEfDemKO5cfPlynQjmmMkiDpcyHa7mvdU+PGh3\n" +
+ "g+OmQxORXMmBkHEnYS1fl3ac3U5sLiHAQBmTKKcLuVQlIU4oDu/K6WEGL9DdPtaK\n" +
+ "gyOOWtSnfHTbT0bZ4IMm+gzdc4bCuEjvYyUhzG0CgYAEN011MAyTqFSvAwN9kjhb\n" +
+ "deYVmmL57GQuF6FP+/S7RgChpIQqimdS4vb7wFYlfaKtNq1V9jwoh51S0kt8qO7n\n" +
+ "ujEHJ2aBnwKJYJbBGV+hBvK/vbvG0TmotaWspmJJ+G6QigHx/Te+0Maw4PO+zTjo\n" +
+ "pdeP8b3JW70LkC+iKBp3swKBgFL/nm32m1tHEjFtehpVHFkSg05Z+jJDATiKlhh0\n" +
+ "YS2Vz+yuTDpE54CFW4M8wZKnXNbWJDBdd6KjIu42kKrA/zTJ5Ox92u1BJXFsk9fk\n" +
+ "xcX++qp5iBGepXZgHEiBMQLcdgY1m3jQl6XXOGSFog0+c4NIE/f1A8PrwI7gAdSt\n" +
+ "56SVAoGBAJp214Fo0oheMTTYKVtXuGiH/v3JNG1jKFgsmHqndf4wy7U6bbNctEzc\n" +
+ "ZXNIacuhWmko6YejMrWNhE57sX812MhXGZq6y0sYZGKtp7oDv8G3rWD6bpZywpcV\n" +
+ "kTtMJxm8J64u6bAkpWG3BocJP9qbXeAbILo1wuXgYqABBrpA9nnc";
+ GithubAppConfiguration githubAppConfiguration = createAppConfigurationForPrivateKey(incompletePrivateKey);
+
+ assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasRootCauseInstanceOf(IOException.class)
+ .hasRootCauseMessage("-----END RSA PRIVATE KEY not found");
+ }
+
+ @Test
+ public void createAppToken_fails_with_IAE_if_privateKey_PKCS8_content_is_corrupted() {
+ String corruptedPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\n" +
+ "MIIEowIBAAKCAQEA6C29ZdvrwHOu7Eewv+xvUd4inCnACTzAHukHKTSY4R16+lRI\n" +
+ "YC5qZ8Xo304J7lLhN4/d4Xnof3lDXZOHthVbJKik4fOuEGbTXTIcuFs3hdJtrJsb\n" +
+ "antv8SOl5iR4fYRAf2AILMdtZI4iMSicBLIIttR+wVXo6NJYMjpj1OuAU3uN8eET\n" +
+ "Gge09oJT3QOUBem7N8uaYi/p5uAfsf2/SVNsoMPV624X4kgNcyj/TMa6BosFJ8Y3\n" +
+ "oeg0Aguk2yuHhAnixDVGoz6N7Go0QjEipVNix2JOOJwpFH4k2iZfM6n+8sJTLilq\n" +
+ "yzT53JW/XI+M5AXVj4OjBJ/2yMPi3RFMNTdgRwIDAQABAoIBACcYBIsRI7oNAIgi\n" +
+ "bh1y1y+mwpce5Inpo8PQovcKNy+4gguCg4lGZ34/sb1f64YoiGmNnOOpXj+QkIpC\n" +
+ "HBjJscYTa2fsWwPB/Jb1qCZWnZu32eW1XEFqtWeaBAYjX/JqgV2xMs8vaTkEQbeb\n" +
+ // "SeH0hEkcsJcnOwdw247hjAu+96WWlyt10ZGgQaWPfXsdtelbaoaturNAVAJHdl9e\n" +
+ // "TIknCIbtLlbz/FtzjtCtdeiWr8gbKdVkshGtA8SKVhXGQwDwENjUkAUtSJ0aXR1t\n" +
+ // "+UjQcTISk7LiiYs0MrJ/CKoJ7mShwx7+YF3hgyqQ0qaqHwt9Yyd7wzWdCgdM5Eha\n" +
+ // "ccioIskCgYEA+EDJmcM5NGu5AYpZ1ogmG6jzsefAlr2NG1PQ/U03twal/B+ygAQb\n" +
+ // "5dholrq+aF+45Hrzfxije3Zrvpb08vxzKAs20lOlJsKftx2zkLR+mNvWTAORuO16\n" +
+ // "lG0c0cgYAKA1ld4R8KB8NmbuNb1w4LYZuyuFIEVmm2B3ca141WNHBwMCgYEA72yK\n" +
+ // "B4+xxomZn6dtbCGQZxziaI9WH/KEfDemKO5cfPlynQjmmMkiDpcyHa7mvdU+PGh3\n" +
+ "g+OmQxORXMmBkHEnYS1fl3ac3U5sLiHAQBmTKKcLuVQlIU4oDu/K6WEGL9DdPtaK\n" +
+ "gyOOWtSnfHTbT0bZ4IMm+gzdc4bCuEjvYyUhzG0CgYAEN011MAyTqFSvAwN9kjhb\n" +
+ "deYVmmL57GQuF6FP+/S7RgChpIQqimdS4vb7wFYlfaKtNq1V9jwoh51S0kt8qO7n\n" +
+ "ujEHJ2aBnwKJYJbBGV+hBvK/vbvG0TmotaWspmJJ+G6QigHx/Te+0Maw4PO+zTjo\n" +
+ "pdeP8b3JW70LkC+iKBp3swKBgFL/nm32m1tHEjFtehpVHFkSg05Z+jJDATiKlhh0\n" +
+ "YS2Vz+yuTDpE54CFW4M8wZKnXNbWJDBdd6KjIu42kKrA/zTJ5Ox92u1BJXFsk9fk\n" +
+ "xcX++qp5iBGepXZgHEiBMQLcdgY1m3jQl6XXOGSFog0+c4NIE/f1A8PrwI7gAdSt\n" +
+ "56SVAoGBAJp214Fo0oheMTTYKVtXuGiH/v3JNG1jKFgsmHqndf4wy7U6bbNctEzc\n" +
+ "ZXNIacuhWmko6YejMrWNhE57sX812MhXGZq6y0sYZGKtp7oDv8G3rWD6bpZywpcV\n" +
+ "kTtMJxm8J64u6bAkpWG3BocJP9qbXeAbILo1wuXgYqABBrpA9nnc\n" +
+ "-----END RSA PRIVATE KEY-----";
+ GithubAppConfiguration githubAppConfiguration = createAppConfigurationForPrivateKey(corruptedPrivateKey);
+
+ assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasCauseInstanceOf(InvalidKeySpecException.class);
+ }
+
+ @Test
+ public void getApplicationJWTToken_throws_ISE_if_conf_is_not_complete() {
+ GithubAppConfiguration githubAppConfiguration = createAppConfiguration(false);
+ assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()))
+ .isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ public void getApplicationJWTToken_returns_token_if_app_config_and_private_key_are_valid() {
+ GithubAppConfiguration githubAppConfiguration = createAppConfiguration(true);
+
+ assertThat(underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).isNotNull();
+ }
+
+ private GithubAppConfiguration createAppConfiguration(boolean validConfiguration) {
+ if (validConfiguration) {
+ return createAppConfiguration();
+ } else {
+ return new GithubAppConfiguration(null, null, null);
+ }
+ }
+
+ private GithubAppConfiguration createAppConfiguration() {
+ return new GithubAppConfiguration(new Random().nextLong(), REAL_PRIVATE_KEY, randomAlphanumeric(5));
+ }
+
+ private GithubAppConfiguration createAppConfigurationForPrivateKey(String privateKey) {
+ long applicationId = new Random().nextInt(654);
+ return new GithubAppConfiguration(applicationId, privateKey, randomAlphabetic(8));
+ }
+
+ private static final String REAL_PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n" +
+ "MIIEowIBAAKCAQEA6C29ZdvrwHOu7Eewv+xvUd4inCnACTzAHukHKTSY4R16+lRI\n" +
+ "YC5qZ8Xo304J7lLhN4/d4Xnof3lDXZOHthVbJKik4fOuEGbTXTIcuFs3hdJtrJsb\n" +
+ "antv8SOl5iR4fYRAf2AILMdtZI4iMSicBLIIttR+wVXo6NJYMjpj1OuAU3uN8eET\n" +
+ "Gge09oJT3QOUBem7N8uaYi/p5uAfsf2/SVNsoMPV624X4kgNcyj/TMa6BosFJ8Y3\n" +
+ "oeg0Aguk2yuHhAnixDVGoz6N7Go0QjEipVNix2JOOJwpFH4k2iZfM6n+8sJTLilq\n" +
+ "yzT53JW/XI+M5AXVj4OjBJ/2yMPi3RFMNTdgRwIDAQABAoIBACcYBIsRI7oNAIgi\n" +
+ "bh1y1y+mwpce5Inpo8PQovcKNy+4gguCg4lGZ34/sb1f64YoiGmNnOOpXj+QkIpC\n" +
+ "HBjJscYTa2fsWwPB/Jb1qCZWnZu32eW1XEFqtWeaBAYjX/JqgV2xMs8vaTkEQbeb\n" +
+ "SeH0hEkcsJcnOwdw247hjAu+96WWlyt10ZGgQaWPfXsdtelbaoaturNAVAJHdl9e\n" +
+ "TIknCIbtLlbz/FtzjtCtdeiWr8gbKdVkshGtA8SKVhXGQwDwENjUkAUtSJ0aXR1t\n" +
+ "+UjQcTISk7LiiYs0MrJ/CKoJ7mShwx7+YF3hgyqQ0qaqHwt9Yyd7wzWdCgdM5Eha\n" +
+ "ccioIskCgYEA+EDJmcM5NGu5AYpZ1ogmG6jzsefAlr2NG1PQ/U03twal/B+ygAQb\n" +
+ "5dholrq+aF+45Hrzfxije3Zrvpb08vxzKAs20lOlJsKftx2zkLR+mNvWTAORuO16\n" +
+ "lG0c0cgYAKA1ld4R8KB8NmbuNb1w4LYZuyuFIEVmm2B3ca141WNHBwMCgYEA72yK\n" +
+ "B4+xxomZn6dtbCGQZxziaI9WH/KEfDemKO5cfPlynQjmmMkiDpcyHa7mvdU+PGh3\n" +
+ "g+OmQxORXMmBkHEnYS1fl3ac3U5sLiHAQBmTKKcLuVQlIU4oDu/K6WEGL9DdPtaK\n" +
+ "gyOOWtSnfHTbT0bZ4IMm+gzdc4bCuEjvYyUhzG0CgYAEN011MAyTqFSvAwN9kjhb\n" +
+ "deYVmmL57GQuF6FP+/S7RgChpIQqimdS4vb7wFYlfaKtNq1V9jwoh51S0kt8qO7n\n" +
+ "ujEHJ2aBnwKJYJbBGV+hBvK/vbvG0TmotaWspmJJ+G6QigHx/Te+0Maw4PO+zTjo\n" +
+ "pdeP8b3JW70LkC+iKBp3swKBgFL/nm32m1tHEjFtehpVHFkSg05Z+jJDATiKlhh0\n" +
+ "YS2Vz+yuTDpE54CFW4M8wZKnXNbWJDBdd6KjIu42kKrA/zTJ5Ox92u1BJXFsk9fk\n" +
+ "xcX++qp5iBGepXZgHEiBMQLcdgY1m3jQl6XXOGSFog0+c4NIE/f1A8PrwI7gAdSt\n" +
+ "56SVAoGBAJp214Fo0oheMTTYKVtXuGiH/v3JNG1jKFgsmHqndf4wy7U6bbNctEzc\n" +
+ "ZXNIacuhWmko6YejMrWNhE57sX812MhXGZq6y0sYZGKtp7oDv8G3rWD6bpZywpcV\n" +
+ "kTtMJxm8J64u6bAkpWG3BocJP9qbXeAbILo1wuXgYqABBrpA9nnc\n" +
+ "-----END RSA PRIVATE KEY-----";
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java
index 9f6196b2333..e1ed3e3fa24 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java
@@ -30,6 +30,7 @@ public class AlmSettingsWsModule extends Module {
DeleteAction.class,
ListAction.class,
ListDefinitionsAction.class,
+ ValidateAction.class,
//Azure alm settings,
CreateAzureAction.class,
UpdateAzureAction.class,
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java
index 84caed6da9f..e19249ecba8 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java
@@ -25,8 +25,6 @@ import org.sonar.api.server.ws.WebService;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.alm.setting.AlmSettingDto;
-import org.sonar.server.almsettings.ws.AlmSettingsSupport;
-import org.sonar.server.almsettings.ws.AlmSettingsWsAction;
import org.sonar.server.user.UserSession;
public class DeleteAction implements AlmSettingsWsAction {
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java
index 4717d1851a1..ebfc8b5ef8c 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java
@@ -22,6 +22,7 @@ package org.sonar.server.almsettings.ws;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
+
import org.sonar.api.server.ws.Change;
import org.sonar.api.server.ws.Request;
import org.sonar.api.server.ws.Response;
@@ -30,8 +31,6 @@ import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.alm.setting.AlmSettingDto;
import org.sonar.db.project.ProjectDto;
-import org.sonar.server.almsettings.ws.AlmSettingsSupport;
-import org.sonar.server.almsettings.ws.AlmSettingsWsAction;
import org.sonar.server.component.ComponentFinder;
import org.sonar.server.user.UserSession;
import org.sonarqube.ws.AlmSettings.AlmSetting;
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
new file mode 100644
index 00000000000..5966cda5773
--- /dev/null
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java
@@ -0,0 +1,145 @@
+/*
+ * 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.server.almsettings.ws;
+
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
+import org.sonar.alm.client.github.GithubApplicationClient;
+import org.sonar.alm.client.github.GithubApplicationClientImpl;
+import org.sonar.alm.client.github.config.GithubAppConfiguration;
+import org.sonar.alm.client.gitlab.GitlabHttpClient;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.server.user.UserSession;
+
+import static org.apache.commons.lang.StringUtils.isBlank;
+
+public class ValidateAction implements AlmSettingsWsAction {
+
+ private static final String PARAM_KEY = "key";
+
+ private final DbClient dbClient;
+ private final UserSession userSession;
+ private final AlmSettingsSupport almSettingsSupport;
+ private final AzureDevOpsHttpClient azureDevOpsHttpClient;
+ private final GitlabHttpClient gitlabHttpClient;
+ private final GithubApplicationClient githubApplicationClient;
+ private final BitbucketServerRestClient bitbucketServerRestClient;
+
+ public ValidateAction(DbClient dbClient, UserSession userSession, AlmSettingsSupport almSettingsSupport,
+ AzureDevOpsHttpClient azureDevOpsHttpClient,
+ GithubApplicationClientImpl githubApplicationClient, GitlabHttpClient gitlabHttpClient,
+ BitbucketServerRestClient bitbucketServerRestClient) {
+ this.dbClient = dbClient;
+ this.userSession = userSession;
+ this.almSettingsSupport = almSettingsSupport;
+ this.azureDevOpsHttpClient = azureDevOpsHttpClient;
+ this.githubApplicationClient = githubApplicationClient;
+ this.gitlabHttpClient = gitlabHttpClient;
+ this.bitbucketServerRestClient = bitbucketServerRestClient;
+ }
+
+ @Override
+ public void define(WebService.NewController context) {
+ WebService.NewAction action = context.createAction("validate")
+ .setDescription("Validate an ALM Setting by checking connectivity and permissions<br/>" +
+ "Requires the 'Administer System' permission")
+ .setSince("8.6")
+ .setHandler(this);
+
+ action.createParam(PARAM_KEY)
+ .setRequired(true)
+ .setMaximumLength(200)
+ .setDescription("Unique key of the ALM settings");
+ }
+
+ @Override
+ public void handle(Request request, Response response) {
+ userSession.checkIsSystemAdministrator();
+ doHandle(request);
+ response.noContent();
+ }
+
+ private void doHandle(Request request) {
+ String key = request.mandatoryParam(PARAM_KEY);
+
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ AlmSettingDto almSettingDto = almSettingsSupport.getAlmSetting(dbSession, key);
+ switch (almSettingDto.getAlm()) {
+ case GITLAB:
+ validateGitlab(almSettingDto);
+ break;
+ case GITHUB:
+ validateGitHub(almSettingDto);
+ break;
+ case BITBUCKET:
+ validateBitbucketServer(almSettingDto);
+ break;
+ case AZURE_DEVOPS:
+ validateAzure(almSettingDto);
+ break;
+ }
+ }
+ }
+
+ private void validateAzure(AlmSettingDto almSettingDto) {
+ try {
+ azureDevOpsHttpClient.checkPAT(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid Azure URL or Personal Access Token", e);
+ }
+ }
+
+ private void validateGitlab(AlmSettingDto almSettingDto) {
+ gitlabHttpClient.checkUrl(almSettingDto.getUrl());
+ gitlabHttpClient.checkToken(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
+ gitlabHttpClient.checkReadPermission(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
+ gitlabHttpClient.checkWritePermission(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
+ }
+
+ private void validateGitHub(AlmSettingDto settings) {
+ long appId;
+ try {
+ appId = Long.parseLong(settings.getAppId());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid appId; " + e.getMessage());
+ }
+ if (isBlank(settings.getClientId())) {
+ throw new IllegalArgumentException("Missing Client Id");
+ }
+ if (isBlank(settings.getClientSecret())) {
+ throw new IllegalArgumentException("Missing Client Secret");
+ }
+ GithubAppConfiguration configuration = new GithubAppConfiguration(appId, settings.getPrivateKey(), settings.getUrl());
+
+ githubApplicationClient.checkApiEndpoint(configuration);
+ githubApplicationClient.checkAppPermissions(configuration);
+ }
+
+ private void validateBitbucketServer(AlmSettingDto almSettingDto) {
+ bitbucketServerRestClient.validateUrl(almSettingDto.getUrl());
+ bitbucketServerRestClient.validateToken(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
+ bitbucketServerRestClient.validateReadPermission(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken());
+ }
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java
index bab08fc152d..bf28d5d8722 100644
--- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java
+++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java
@@ -31,7 +31,7 @@ public class AlmSettingsWsModuleTest {
public void verify_count_of_added_components() {
ComponentContainer container = new ComponentContainer();
new AlmSettingsWsModule().configure(container);
- assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 13);
+ assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 14);
}
} \ No newline at end of file
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
new file mode 100644
index 00000000000..8f01e672e53
--- /dev/null
+++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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.server.almsettings.ws;
+
+import org.junit.Rule;
+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.bitbucketserver.BitbucketServerRestClient;
+import org.sonar.alm.client.github.GithubApplicationClientImpl;
+import org.sonar.alm.client.github.config.GithubAppConfiguration;
+import org.sonar.alm.client.gitlab.GitlabHttpClient;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbTester;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.almsettings.MultipleAlmFeatureProvider;
+import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.WsActionTester;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.groups.Tuple.tuple;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class ValidateActionTest {
+
+ @Rule
+ public ExpectedException expectedException = ExpectedException.none();
+ @Rule
+ public UserSessionRule userSession = UserSessionRule.standalone();
+ @Rule
+ public DbTester db = DbTester.create();
+
+ private final MultipleAlmFeatureProvider multipleAlmFeatureProvider = mock(MultipleAlmFeatureProvider.class);
+ private final ComponentFinder componentFinder = new ComponentFinder(db.getDbClient(), null);
+ private final AlmSettingsSupport almSettingsSupport = new AlmSettingsSupport(db.getDbClient(), userSession, componentFinder, multipleAlmFeatureProvider);
+ private final AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class);
+ private final GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class);
+ private final GithubApplicationClientImpl githubApplicationClient = mock(GithubApplicationClientImpl.class);
+ private final BitbucketServerRestClient bitbucketServerRestClient = mock(BitbucketServerRestClient.class);
+ private final WsActionTester ws = new WsActionTester(
+ new ValidateAction(db.getDbClient(), userSession, almSettingsSupport, azureDevOpsHttpClient, githubApplicationClient, gitlabHttpClient,
+ bitbucketServerRestClient));
+
+ @Test
+ public void fail_when_key_does_not_match_existing_alm_setting() {
+ UserDto user = db.users().insertUser();
+ userSession.logIn(user).setSystemAdministrator();
+
+ expectedException.expect(NotFoundException.class);
+ expectedException.expectMessage("ALM setting with key 'unknown' cannot be found");
+
+ ws.newRequest()
+ .setParam("key", "unknown")
+ .execute();
+ }
+
+ @Test
+ public void fail_when_missing_administer_system_permission() {
+ UserDto user = db.users().insertUser();
+ userSession.logIn(user);
+
+ expectedException.expect(ForbiddenException.class);
+
+ ws.newRequest()
+ .setParam("key", "any key")
+ .execute();
+ }
+
+ @Test
+ public void gitlab_validation_checks() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitlabAlmSetting());
+
+ ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute();
+
+ verify(gitlabHttpClient).checkUrl(almSetting.getUrl());
+ verify(gitlabHttpClient).checkToken(almSetting.getUrl(), almSetting.getPersonalAccessToken());
+ verify(gitlabHttpClient).checkReadPermission(almSetting.getUrl(), almSetting.getPersonalAccessToken());
+ verify(gitlabHttpClient).checkWritePermission(almSetting.getUrl(), almSetting.getPersonalAccessToken());
+ }
+
+ @Test
+ public void github_validation_checks() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setClientId("clientId")
+ .setClientSecret("clientSecret")));
+
+ ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute();
+
+ ArgumentCaptor<GithubAppConfiguration> configurationArgumentCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class);
+ verify(githubApplicationClient).checkApiEndpoint(configurationArgumentCaptor.capture());
+ verify(githubApplicationClient).checkAppPermissions(configurationArgumentCaptor.capture());
+
+ assertThat(configurationArgumentCaptor.getAllValues()).hasSize(2)
+ .extracting(GithubAppConfiguration::getApiEndpoint)
+ .contains(almSetting.getUrl(), almSetting.getUrl());
+ }
+
+ @Test
+ public void github_validation_checks_invalid_appId() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setAppId("abc")
+ .setClientId("clientId").setClientSecret("clientSecret")));
+
+ assertThatThrownBy(() -> ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid appId; For input string: \"abc\"");
+ }
+
+ @Test
+ public void github_validation_checks_missing_clientId() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setClientSecret("clientSecret")));
+
+ assertThatThrownBy(() -> ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Missing Client Id");
+ }
+
+ @Test
+ public void github_validation_checks_missing_clientSecret() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setClientId("clientId")));
+
+ assertThatThrownBy(() -> ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Missing Client Secret");
+
+ }
+
+ @Test
+ public void bitbucketServer_validation_checks() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertBitbucketAlmSetting());
+
+ ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute();
+
+ verify(bitbucketServerRestClient).validateUrl(almSetting.getUrl());
+ verify(bitbucketServerRestClient).validateToken(almSetting.getUrl(), almSetting.getPersonalAccessToken());
+ verify(bitbucketServerRestClient).validateReadPermission(almSetting.getUrl(), almSetting.getPersonalAccessToken());
+ }
+
+ @Test
+ public void azure_devops_validation_checks() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertAzureAlmSetting());
+
+ ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute();
+
+ verify(azureDevOpsHttpClient).checkPAT(almSetting.getUrl(), almSetting.getPersonalAccessToken());
+ }
+
+ @Test
+ public void azure_devops_validation_check_fails() {
+ AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertAzureAlmSetting());
+
+ doThrow(IllegalArgumentException.class)
+ .when(azureDevOpsHttpClient).checkPAT(almSetting.getUrl(), almSetting.getPersonalAccessToken());
+
+ assertThatThrownBy(() -> ws.newRequest()
+ .setParam("key", almSetting.getKey())
+ .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid Azure URL or Personal Access Token");
+ }
+
+ private AlmSettingDto insertAlmSetting(AlmSettingDto almSettingDto) {
+ UserDto user = db.users().insertUser();
+ userSession.logIn(user).setSystemAdministrator();
+ return almSettingDto;
+ }
+
+ @Test
+ public void definition() {
+ WebService.Action def = ws.getDef();
+
+ assertThat(def.since()).isEqualTo("8.6");
+ assertThat(def.isPost()).isFalse();
+ assertThat(def.params())
+ .extracting(WebService.Param::key, WebService.Param::isRequired)
+ .containsExactlyInAnyOrder(tuple("key", true));
+ }
+
+}
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 039039fc8f0..adb64eaeea3 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,11 +20,13 @@
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.bitbucketserver.BitbucketServerRestClient;
import org.sonar.alm.client.github.GithubApplicationClientImpl;
import org.sonar.alm.client.github.GithubApplicationHttpClientImpl;
+import org.sonar.alm.client.github.security.GithubAppSecurityImpl;
import org.sonar.alm.client.gitlab.GitlabHttpClient;
import org.sonar.api.profiles.AnnotationProfileParser;
import org.sonar.api.profiles.XMLProfileParser;
@@ -496,6 +498,7 @@ public class PlatformLevel4 extends PlatformLevel {
// ALM integrations
TimeoutConfigurationImpl.class,
ImportHelper.class,
+ GithubAppSecurityImpl.class,
GithubApplicationClientImpl.class,
GithubApplicationHttpClientImpl.class,
BitbucketServerRestClient.class,
diff --git a/sonar-application/build.gradle b/sonar-application/build.gradle
index 4b433567978..abfeca30e91 100644
--- a/sonar-application/build.gradle
+++ b/sonar-application/build.gradle
@@ -165,7 +165,7 @@ zip.doFirst {
// Check the size of the archive
zip.doLast {
def minLength = 236000000
- def maxLength = 251000000
+ def maxLength = 256000000
def length = archiveFile.get().asFile.length()
if (length < minLength)