]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14372 move alm validation endpoint to CE
authorZipeng WU <zipeng.wu@sonarsource.com>
Mon, 1 Feb 2021 11:39:03 +0000 (12:39 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 4 Feb 2021 20:07:08 +0000 (20:07 +0000)
28 files changed:
build.gradle
server/sonar-alm-client/build.gradle
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java [new file with mode: 0644]
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
sonar-application/build.gradle

index 4dffcea0a78c26aaf93d2816605a50ca800c5f36..f1c933423fbb92b474801cb10a245a4c163883d7 100644 (file)
@@ -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'
index f546848d6309f8b010823f1aeae166e1eb7c2481..e33c1f3de7afefd55f444d82b26f8301f1961f08 100644 (file)
@@ -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')
 
index 7a3ba3b63034d83fb1f75a92ed219c9ad638f7e6..e410ac25f565ab43c463cd1e3150e3975cbb8695 100644 (file)
@@ -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 (file)
index 0000000..633bc0d
--- /dev/null
@@ -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 (file)
index 0000000..df01dc6
--- /dev/null
@@ -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;
+  }
+
+}
index eb98cdcafbc1ccd6da6169b8a574f099be05cc83..13260a3e18c20b585e8c0abd0ccc407b11b00524 100644 (file)
@@ -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.
    */
index 361d206a6a1451fde09b6ff4d827bc9a5d105bec..3fc473526739a7c373f1acd9fef42f80e7b5d428 100644 (file)
 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 (file)
index 0000000..c7c6bc9
--- /dev/null
@@ -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 (file)
index 0000000..c1f97be
--- /dev/null
@@ -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 (file)
index 0000000..d05edea
--- /dev/null
@@ -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 (file)
index 0000000..a5ed35b
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.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 (file)
index 0000000..5a6aabd
--- /dev/null
@@ -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);
+    }
+  }
+
+}
index e0d453c56e2dc22442ef42a292350e10b7903512..f621e3d5a1d2c0f45364bdcec06144dbc13073d3 100644 (file)
@@ -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 (file)
index 0000000..2befa10
--- /dev/null
@@ -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 (file)
index 0000000..9694830
--- /dev/null
@@ -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 (file)
index 0000000..39eac29
--- /dev/null
@@ -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);
+  }
+
+}
index 75d800ae4d41d80c6ea2b811a4b7c4e7ae2a598b..4cf51c3f5dfec15848307b75690596f7626ebeec 100644 (file)
@@ -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,16 +57,142 @@ 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 {
@@ -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 (file)
index 0000000..6dd3e13
--- /dev/null
@@ -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 (file)
index 0000000..f9e5560
--- /dev/null
@@ -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 (file)
index 0000000..d558e2b
--- /dev/null
@@ -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-----";
+}
index 9f6196b233356ca22d0ca12274187fbafaf641bc..e1ed3e3fa247b7b545ff3b8326c02c69471fff01 100644 (file)
@@ -30,6 +30,7 @@ public class AlmSettingsWsModule extends Module {
       DeleteAction.class,
       ListAction.class,
       ListDefinitionsAction.class,
+      ValidateAction.class,
       //Azure alm settings,
       CreateAzureAction.class,
       UpdateAzureAction.class,
index 84caed6da9fd4267662c48e2c7d179eeddb4b991..e19249ecba8a2ef73a15e71d795deb5632e8915f 100644 (file)
@@ -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 {
index 4717d1851a1b4a1601a6344486abca52a9f22fcd..ebfc8b5ef8c08deb860f3b1bf8435a6314caab64 100644 (file)
@@ -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 (file)
index 0000000..5966cda
--- /dev/null
@@ -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());
+  }
+}
index bab08fc152d5c8b175bc91448adea6663a5bc0b5..bf28d5d87229107ae4ea6d7e822ea1f11e2e8d69 100644 (file)
@@ -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 (file)
index 0000000..8f01e67
--- /dev/null
@@ -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));
+  }
+
+}
index 039039fc8f089887fb9386020cad4760f3b0be44..adb64eaeea3aa5cc1ea5c9e23029ecb3abde0dd4 100644 (file)
 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,
index 4b4335679788d75cae79347f18acd57f2138a598..abfeca30e91b0341a54c0b8fee7b815c10970493 100644 (file)
@@ -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)