From c1966338ee963dbd6e6f636635f390cfeb334af5 Mon Sep 17 00:00:00 2001 From: Antoine Vigneau Date: Fri, 2 Jun 2023 14:14:30 +0200 Subject: [PATCH] SONAR-19346 GitHub config check available in Community Edition --- server/sonar-alm-client/build.gradle | 1 + .../github/GithubApplicationClient.java | 9 + .../github/GithubApplicationClientImpl.java | 76 ++++- .../github/config/ConfigCheckResult.java | 51 +++ .../github/config/GithubAppInstallation.java | 24 ++ .../GithubProvisioningConfigValidator.java | 167 ++++++++++ .../GithubApplicationClientImplTest.java | 139 ++++++++- ...GithubProvisioningConfigValidatorTest.java | 294 ++++++++++++++++++ .../ws/github/config/CheckActionIT.java | 107 +++++++ .../ws/AlmIntegrationsWSModule.java | 2 + .../ws/github/GithubProvisioningAction.java | 25 ++ .../ws/github/GithubProvisioningWs.java | 46 +++ .../ws/github/config/CheckAction.java | 69 ++++ .../ws/github/config/check-example.json | 32 ++ .../ws/github/GithubProvisioningWsTest.java | 49 +++ .../platformlevel/PlatformLevel4.java | 4 + 16 files changed, 1093 insertions(+), 2 deletions(-) create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java create mode 100644 server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/config/CheckActionIT.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningAction.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWs.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/config/CheckAction.java create mode 100644 server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/github/config/check-example.json create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWsTest.java diff --git a/server/sonar-alm-client/build.gradle b/server/sonar-alm-client/build.gradle index a15661a6cba..fa06b389318 100644 --- a/server/sonar-alm-client/build.gradle +++ b/server/sonar-alm-client/build.gradle @@ -11,6 +11,7 @@ dependencies { api 'com.auth0:java-jwt' api 'org.bouncycastle:bcpkix-jdk15on:1.70' api 'org.sonarsource.api.plugin:sonar-plugin-api' + api project(':server:sonar-auth-github') testImplementation project(':sonar-plugin-api-impl') 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 7446d304ec6..11848ba0f4a 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,7 @@ 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.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; import org.sonar.alm.client.github.security.UserAccessToken; import org.sonar.api.server.ServerSide; @@ -43,11 +44,19 @@ public interface GithubApplicationClient { */ UserAccessToken createUserAccessToken(String appUrl, String clientId, String clientSecret, String code); + GithubBinding.GsonApp getApp(GithubAppConfiguration githubAppConfiguration); + /** * Lists all the organizations accessible to the access token provided. */ Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize); + /** + * Retrieve all installations of the GitHub app, filtering out the ones not whitelisted in GitHub Settings (if set) + * @throws IllegalArgumentException if one of the arguments is invalid (for example, wrong private key) + */ + List getWhitelistedGithubAppInstallations(GithubAppConfiguration githubAppConfiguration); + /** * Lists all the repositories of the provided organization accessible to the access token provided. */ 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 eb7a4718a34..4b771efee8e 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 @@ -29,6 +29,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; import org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse; @@ -36,6 +37,7 @@ 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.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; import org.sonar.alm.client.github.security.AppToken; import org.sonar.alm.client.github.security.GithubAppSecurity; @@ -44,10 +46,14 @@ 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 org.sonar.auth.github.GitHubSettings; +import org.sonar.server.exceptions.ServerException; +import org.sonarqube.ws.client.HttpException; 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_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; @@ -61,10 +67,12 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { protected final GithubApplicationHttpClient appHttpClient; protected final GithubAppSecurity appSecurity; + private final GitHubSettings gitHubSettings; - public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient, GithubAppSecurity appSecurity) { + public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings) { this.appHttpClient = appHttpClient; this.appSecurity = appSecurity; + this.gitHubSettings = gitHubSettings; } private static void checkPageArgs(int page, int pageSize) { @@ -160,6 +168,53 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { } } + @Override + public List getWhitelistedGithubAppInstallations(GithubAppConfiguration githubAppConfiguration) { + GithubBinding.GsonInstallation[] gsonAppInstallations = fetchAppInstallationsFromGithub(githubAppConfiguration); + Set allowedOrganizations = gitHubSettings.getOrganizations(); + return convertToGithubAppInstallationAndFilterWhitelisted(gsonAppInstallations, allowedOrganizations); + } + + private static List convertToGithubAppInstallationAndFilterWhitelisted(GithubBinding.GsonInstallation[] gsonAppInstallations, + Set allowedOrganizations) { + return Arrays.stream(gsonAppInstallations) + .filter(appInstallation -> appInstallation.getAccount().getType().equalsIgnoreCase("Organization")) + .map(GithubApplicationClientImpl::toGithubAppInstallation) + .filter(appInstallation -> isOrganizationWhiteListed(allowedOrganizations, appInstallation.organizationName())) + .toList(); + } + + private static GithubAppInstallation toGithubAppInstallation(GithubBinding.GsonInstallation gsonInstallation) { + return new GithubAppInstallation( + Long.toString(gsonInstallation.getId()), + gsonInstallation.getAccount().getLogin(), + gsonInstallation.getPermissions(), + org.apache.commons.lang.StringUtils.isNotEmpty(gsonInstallation.getSuspendedAt())); + } + + private static boolean isOrganizationWhiteListed(Set allowedOrganizations, String organizationName) { + return allowedOrganizations.isEmpty() || allowedOrganizations.contains(organizationName); + } + + private GithubBinding.GsonInstallation[] fetchAppInstallationsFromGithub(GithubAppConfiguration githubAppConfiguration) { + AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()); + String endpoint = "/app/installations"; + return get(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, + GithubBinding.GsonInstallation[].class).orElseThrow( + () -> new IllegalStateException("An error occurred when retrieving your GitHup App installations. " + + "It might be related to your GitHub App configuration or a connectivity problem.")); + } + + protected Optional get(String baseUrl, AccessToken token, String endPoint, Class gsonClass) { + try { + GetResponse response = appHttpClient.get(baseUrl, token, endPoint); + return handleResponse(response, endPoint, gsonClass); + } catch (Exception e) { + LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e); + return Optional.empty(); + } + } + @Override public Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize) { checkPageArgs(page, pageSize); @@ -242,6 +297,25 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { } } + @Override + public GithubBinding.GsonApp getApp(GithubAppConfiguration githubAppConfiguration) { + AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()); + String endpoint = "/app"; + return getOrThrowIfNotHttpOk(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, GithubBinding.GsonApp.class); + } + + private T getOrThrowIfNotHttpOk(String baseUrl, AccessToken token, String endPoint, Class gsonClass) { + try { + GetResponse response = appHttpClient.get(baseUrl, token, endPoint); + if (response.getCode() != HTTP_OK) { + throw new HttpException(baseUrl + endPoint, response.getCode(), response.getContent().orElse("")); + } + return handleResponse(response, endPoint, gsonClass).orElseThrow(() -> new ServerException(HTTP_INTERNAL_ERROR, "Http response withuot content")); + } catch (IOException e) { + throw new ServerException(HTTP_INTERNAL_ERROR, e.getMessage()); + } + } + protected static Optional handleResponse(GithubApplicationHttpClient.Response response, String endPoint, Class gsonClass) { try { diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java new file mode 100644 index 00000000000..1af06e2d4d1 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/ConfigCheckResult.java @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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.gson.annotations.SerializedName; +import java.util.List; +import javax.annotation.Nullable; + +public record ConfigCheckResult(@SerializedName("application") ApplicationStatus application, @SerializedName("installations") List installations) { + + public record ConfigStatus(@SerializedName("status") String status, @Nullable @SerializedName("errorMessage") String errorMessage) { + + public static final String SUCCESS_STATUS = "SUCCESS"; + public static final String FAILED_STATUS = "FAILED"; + + public static final ConfigStatus SUCCESS = new ConfigStatus(SUCCESS_STATUS); + + public ConfigStatus(String status) { + this(status, null); + } + + public static ConfigStatus failed(String errorMessage) { + return new ConfigStatus(FAILED_STATUS, errorMessage); + } + + } + + public record ApplicationStatus(@SerializedName("jit") ConfigStatus jit, @SerializedName("autoProvisioning") ConfigStatus autoProvisioning) { + } + + public record InstallationStatus(@SerializedName("organization") String organization, @SerializedName("jit") ConfigStatus jit, + @SerializedName("autoProvisioning") ConfigStatus autoProvisioning) { + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java new file mode 100644 index 00000000000..5f59f48c617 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppInstallation.java @@ -0,0 +1,24 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 org.sonar.alm.client.github.GithubBinding; + +public record GithubAppInstallation(String installationId, String organizationName, GithubBinding.Permissions permissions, boolean isSuspended) {} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java new file mode 100644 index 00000000000..3e6bb8f001f --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidator.java @@ -0,0 +1,167 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubBinding.Permissions; +import org.sonar.api.ce.ComputeEngineSide; +import org.sonar.api.server.ServerSide; +import org.sonar.auth.github.GitHubSettings; +import org.sonarqube.ws.client.HttpException; + +import static java.lang.Long.parseLong; +import static org.sonar.alm.client.github.GithubBinding.GsonApp; +import static org.sonar.alm.client.github.config.ConfigCheckResult.ApplicationStatus; +import static org.sonar.alm.client.github.config.ConfigCheckResult.ConfigStatus; +import static org.sonar.alm.client.github.config.ConfigCheckResult.InstallationStatus; + +@ServerSide +@ComputeEngineSide +public class GithubProvisioningConfigValidator { + + private static final ConfigStatus APP_NOT_FOUND_STATUS = ConfigStatus.failed("Github App not found"); + private static final String MEMBERS_PERMISSION = "Organization permissions -> Members"; + + private static final String EMAILS_PERMISSION = "Account permissions -> Email addresses"; + + private static final ConfigStatus INVALID_APP_CONFIG_STATUS = ConfigStatus.failed("The GitHub App configuration is not complete."); + private static final ConfigStatus INVALID_APP_ID_STATUS = ConfigStatus.failed("GitHub App ID must be a number."); + private static final ConfigStatus SUSPENDED_INSTALLATION_STATUS = ConfigStatus.failed("Installation suspended"); + private static final ConfigStatus NO_INSTALLATION_FOUND_STATUS = ConfigStatus.failed( + "The GitHub App is not installed on any organizations or the organization is not white-listed."); + private static final ConfigCheckResult NO_INSTALLATIONS_RESULT = new ConfigCheckResult( + new ApplicationStatus( + NO_INSTALLATION_FOUND_STATUS, + NO_INSTALLATION_FOUND_STATUS), + List.of()); + + private final GithubApplicationClient githubClient; + private final GitHubSettings gitHubSettings; + + public GithubProvisioningConfigValidator(GithubApplicationClient githubClient, GitHubSettings gitHubSettings) { + this.githubClient = githubClient; + this.gitHubSettings = gitHubSettings; + } + + public ConfigCheckResult checkConfig() { + Optional appId = getAppId(); + if (appId.isEmpty()) { + return failedApplicationStatus(INVALID_APP_ID_STATUS); + } + GithubAppConfiguration githubAppConfiguration = new GithubAppConfiguration(appId.get(), gitHubSettings.privateKey(), gitHubSettings.apiURLOrDefault()); + return checkConfig(githubAppConfiguration); + } + + private Optional getAppId() { + try { + return Optional.of(parseLong(gitHubSettings.appId())); + } catch (NumberFormatException numberFormatException) { + return Optional.empty(); + } + } + + public ConfigCheckResult checkConfig(GithubAppConfiguration githubAppConfiguration) { + if (!githubAppConfiguration.isComplete()) { + return failedApplicationStatus(INVALID_APP_CONFIG_STATUS); + } + + try { + GsonApp app = githubClient.getApp(githubAppConfiguration); + return checkNonEmptyConfig(githubAppConfiguration, app); + } catch (HttpException e) { + return failedApplicationStatus( + ConfigStatus.failed("Error response from GitHub: " + e.getMessage())); + } + } + + private static ConfigCheckResult failedApplicationStatus(ConfigStatus configStatus) { + return new ConfigCheckResult(new ApplicationStatus(configStatus, configStatus), List.of()); + } + + private ConfigCheckResult checkNonEmptyConfig(GithubAppConfiguration githubAppConfiguration, GsonApp app) { + ApplicationStatus appStatus = checkNonEmptyAppConfig(app); + List installations = checkInstallations(githubAppConfiguration, appStatus); + if (installations.isEmpty()) { + return NO_INSTALLATIONS_RESULT; + } + return new ConfigCheckResult( + appStatus, + installations); + } + + private static ApplicationStatus checkNonEmptyAppConfig(GsonApp app) { + return new ApplicationStatus( + jitAppConfigStatus(app.getPermissions()), + autoProvisioningAppConfigStatus(app.getPermissions())); + } + + private static ConfigStatus jitAppConfigStatus(Permissions permissions) { + if (permissions.getEmails() == null) { + return failedStatus(List.of(EMAILS_PERMISSION)); + } + return ConfigStatus.SUCCESS; + } + + private static ConfigStatus autoProvisioningAppConfigStatus(Permissions permissions) { + List missingPermissions = new ArrayList<>(); + if (permissions.getEmails() == null) { + missingPermissions.add(EMAILS_PERMISSION); + } + if (permissions.getMembers() == null) { + missingPermissions.add(MEMBERS_PERMISSION); + } + if (missingPermissions.isEmpty()) { + return ConfigStatus.SUCCESS; + } + return failedStatus(missingPermissions); + } + + private static ConfigStatus failedStatus(List missingPermissions) { + return ConfigStatus.failed("Missing permissions: " + String.join(",", missingPermissions)); + } + + private List checkInstallations(GithubAppConfiguration githubAppConfiguration, ApplicationStatus appStatus) { + return githubClient.getWhitelistedGithubAppInstallations(githubAppConfiguration) + .stream() + .map(installation -> statusForInstallation(installation, appStatus)) + .toList(); + } + + private static InstallationStatus statusForInstallation(GithubAppInstallation installation, ApplicationStatus appStatus) { + if (installation.isSuspended()) { + return new InstallationStatus(installation.organizationName(), SUSPENDED_INSTALLATION_STATUS, SUSPENDED_INSTALLATION_STATUS); + } + return new InstallationStatus( + installation.organizationName(), + appStatus.jit(), + autoProvisioningInstallationConfigStatus(installation.permissions())); + } + + private static ConfigStatus autoProvisioningInstallationConfigStatus(Permissions permissions) { + if (permissions.getMembers() == null) { + return failedStatus(List.of(MEMBERS_PERMISSION)); + } + return ConfigStatus.SUCCESS; + } + +} 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 fbe8aa95ca0..2c5e4a4726b 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 @@ -23,19 +23,24 @@ import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.io.IOException; +import java.util.List; import java.util.Optional; +import java.util.Set; import javax.annotation.Nullable; 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.config.GithubAppInstallation; 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.testfixtures.log.LogTester; import org.sonar.api.utils.log.LoggerLevel; +import org.sonar.auth.github.GitHubSettings; +import org.sonarqube.ws.client.HttpException; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; @@ -53,12 +58,43 @@ import static org.mockito.Mockito.when; @RunWith(DataProviderRunner.class) public class GithubApplicationClientImplTest { + private static final String APP_JWT_TOKEN = "APP_TOKEN_JWT"; + private static final String PAYLOAD_2_ORGS = """ + [ + { + "id": 1, + "account": { + "login": "org1", + "type": "Organization" + }, + "target_type": "Organization", + "permissions": { + "members": "read", + "metadata": "read" + }, + "suspended_at": "2023-05-30T08:40:55Z" + }, + { + "id": 2, + "account": { + "login": "org2", + "type": "Organization" + }, + "target_type": "Organization", + "permissions": { + "members": "read", + "metadata": "read" + } + } + ]"""; + @ClassRule 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 GitHubSettings gitHubSettings = mock(GitHubSettings.class); private GithubApplicationClient underTest; private String appUrl = "Any URL"; @@ -66,7 +102,7 @@ public class GithubApplicationClientImplTest { @Before public void setup() { when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl); - underTest = new GithubApplicationClientImpl(httpClient, appSecurity); + underTest = new GithubApplicationClientImpl(httpClient, appSecurity, gitHubSettings); logTester.clear(); } @@ -242,6 +278,31 @@ public class GithubApplicationClientImplTest { verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"); } + @Test + public void getApp_returns_id() throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app")) + .thenReturn(new OkGetResponse("{\"installations_count\": 2}")); + + assertThat(underTest.getApp(githubAppConfiguration).getInstallationsCount()).isEqualTo(2L); + } + + @Test + public void getApp_whenStatusCodeIsNotOk_shouldThrowHttpException() throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app")) + .thenReturn(new ErrorGetResponse(418, "I'm a teapot")); + + assertThatThrownBy(() -> underTest.getApp(githubAppConfiguration)) + .isInstanceOfSatisfying(HttpException.class, httpException -> { + assertThat(httpException.code()).isEqualTo(418); + assertThat(httpException.url()).isEqualTo("Any URL/app"); + assertThat(httpException.content()).isEqualTo("I'm a teapot"); + }); + } + @DataProvider public static Object[][] githubServers() { return new Object[][] { @@ -392,6 +453,82 @@ public class GithubApplicationClientImplTest { assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat"); } + @Test + public void getWhitelistedGithubAppInstallations_whenWhitelistNotSpecified_doesNotFilter() throws IOException { + List allOrgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS); + assertOrgDeserialization(allOrgInstallations); + } + + private static void assertOrgDeserialization(List orgs) { + GithubAppInstallation org1 = orgs.get(0); + assertThat(org1.installationId()).isEqualTo("1"); + assertThat(org1.organizationName()).isEqualTo("org1"); + assertThat(org1.permissions().getMembers()).isEqualTo("read"); + assertThat(org1.isSuspended()).isTrue(); + + GithubAppInstallation org2 = orgs.get(1); + assertThat(org2.installationId()).isEqualTo("2"); + assertThat(org2.organizationName()).isEqualTo("org2"); + assertThat(org2.permissions().getMembers()).isEqualTo("read"); + assertThat(org2.isSuspended()).isFalse(); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenWhitelistSpecified_filtersWhitelistedOrgs() throws IOException { + when(gitHubSettings.getOrganizations()).thenReturn(Set.of("org2")); + List orgInstallations = getGithubAppInstallationsFromGithubResponse(PAYLOAD_2_ORGS); + assertThat(orgInstallations) + .hasSize(1) + .extracting(GithubAppInstallation::organizationName) + .containsExactlyInAnyOrder("org2"); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenEmptyResponse_shouldReturnEmpty() throws IOException { + List allOrgInstallations = getGithubAppInstallationsFromGithubResponse("[]"); + assertThat(allOrgInstallations).isEmpty(); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenNoOrganization_shouldReturnEmpty() throws IOException { + List allOrgInstallations = getGithubAppInstallationsFromGithubResponse(""" + [ + { + "id": 1, + "account": { + "login": "user1", + "type": "User" + }, + "target_type": "User", + "permissions": { + "metadata": "read" + } + } + ]"""); + assertThat(allOrgInstallations).isEmpty(); + } + + private List getGithubAppInstallationsFromGithubResponse(String content) throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app/installations")) + .thenReturn(new OkGetResponse(content)); + return underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration); + } + + @Test + public void getWhitelistedGithubAppInstallations_whenGithubReturnsError_shouldThrow() throws IOException { + AppToken appToken = new AppToken(APP_JWT_TOKEN); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken); + when(httpClient.get(appUrl, appToken, "/app/installations")) + .thenReturn(new ErrorGetResponse()); + + assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("An error occurred when retrieving your GitHup App installations. " + + "It might be related to your GitHub App configuration or a connectivity problem."); + } + @Test public void listRepositories_fail_on_failure() throws IOException { String appUrl = "https://github.sonarsource.com"; diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java new file mode 100644 index 00000000000..e743102c734 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubProvisioningConfigValidatorTest.java @@ -0,0 +1,294 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 java.util.Arrays; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubBinding.GsonApp; +import org.sonar.alm.client.github.GithubBinding.Permissions; +import org.sonar.auth.github.GitHubSettings; +import org.sonarqube.ws.client.HttpException; + +import static java.lang.Long.parseLong; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.alm.client.github.config.ConfigCheckResult.ConfigStatus; +import static org.sonar.alm.client.github.config.ConfigCheckResult.InstallationStatus; + +@RunWith(MockitoJUnitRunner.class) +public class GithubProvisioningConfigValidatorTest { + + private static final String SUCCESS_STATUS = "SUCCESS"; + private static final String GITHUB_CALL_FAILED = "Error response from GitHub: GitHub call failed."; + private static final String INVALID_APP_ID_STATUS = "GitHub App ID must be a number."; + private static final String INCOMPLETE_APP_CONFIG_STATUS = "The GitHub App configuration is not complete."; + private static final String MISSING_EMAIL_PERMISSION = "Missing permissions: Account permissions -> Email addresses"; + private static final String MISSING_MEMBERS_PERMISSION = "Missing permissions: Organization permissions -> Members"; + private static final String MISSING_EMAILS_AND_MEMBERS_PERMISSION = "Missing permissions: Account permissions -> Email addresses,Organization permissions -> Members"; + private static final String NO_INSTALLATIONS_STATUS = "The GitHub App is not installed on any organizations or the organization is not white-listed."; + private static final String SUSPENDED_INSTALLATION = "Installation suspended"; + + private static final ConfigStatus SUCCESS_CHECK = new ConfigStatus(SUCCESS_STATUS, null); + public static final String APP_ID = "1"; + public static final String PRIVATE_KEY = "secret"; + public static final String URL = "url"; + @Mock + private GithubApplicationClient githubClient; + + @Mock + private GitHubSettings gitHubSettings; + + @InjectMocks + private GithubProvisioningConfigValidator configValidator; + + @Test + public void checkConfig_whenAppIdIsNull_shouldReturnFailedAppCheck() { + when(gitHubSettings.appId()).thenReturn(null); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + } + @Test + public void checkConfig_whenAppIdNotValid_shouldReturnFailedAppCheck() { + when(gitHubSettings.appId()).thenReturn("not a number"); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(INVALID_APP_ID_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + } + + @Test + public void checkConfig_whenGithubAppConfigurationNotComplete_shouldReturnFailedAppCheck() { + when(gitHubSettings.appId()).thenReturn(APP_ID); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(INCOMPLETE_APP_CONFIG_STATUS)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(INCOMPLETE_APP_CONFIG_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + } + + @Test + public void checkConfig_whenErrorWhileFetchingTheApp_shouldReturnFailedAppCheck() { + mockGithubConfiguration(); + ArgumentCaptor appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + + HttpException httpException = mock(HttpException.class); + when(httpException.getMessage()).thenReturn("GitHub call failed."); + + when(githubClient.getApp(appConfigurationCaptor.capture())).thenThrow(httpException); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(GITHUB_CALL_FAILED)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(GITHUB_CALL_FAILED)); + assertThat(checkResult.installations()).isEmpty(); + } + + @Test + public void checkConfig_whenAppDoesntHaveEmailsPermissions_shouldReturnFailedAppJitCheck() { + mockGithubConfiguration(); + ArgumentCaptor appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + GsonApp githubApp = mockGithubApp(appConfigurationCaptor); + + when(githubApp.getPermissions()).thenReturn(new Permissions()); + mockOrganizationsWithoutPermissions(appConfigurationCaptor, "org1", "org2"); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(MISSING_EMAILS_AND_MEMBERS_PERMISSION)); + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(MISSING_EMAIL_PERMISSION)); + assertThat(checkResult.installations()).hasSize(2); + assertThat(checkResult.installations()) + .extracting(InstallationStatus::jit, InstallationStatus::autoProvisioning) + .containsExactly( + tuple(ConfigStatus.failed(MISSING_EMAIL_PERMISSION), ConfigStatus.failed(MISSING_MEMBERS_PERMISSION)), + tuple(ConfigStatus.failed(MISSING_EMAIL_PERMISSION), ConfigStatus.failed(MISSING_MEMBERS_PERMISSION))); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + @Test + public void checkConfig_whenAppDoesntHaveMembersPermissions_shouldReturnFailedAppAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + + GsonApp githubApp = mockGithubApp(appConfigurationCaptor); + when(githubApp.getPermissions()).thenReturn(new Permissions(null, null, "read")); + mockOrganizations(appConfigurationCaptor, "org1", "org2"); + + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.SUCCESS); + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(MISSING_MEMBERS_PERMISSION)); + assertThat(checkResult.installations()).hasSize(2); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + } + + @Test + public void checkConfig_whenNoInstallationsAreReturned_shouldReturnFailedAppAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockOrganizationsWithoutPermissions(appConfigurationCaptor); + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.failed(NO_INSTALLATIONS_STATUS)); + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.failed(NO_INSTALLATIONS_STATUS)); + assertThat(checkResult.installations()).isEmpty(); + + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + @Test + public void checkConfig_whenInstallationsDoesntHaveMembersPermissions_shouldReturnFailedAppAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockOrganizationsWithoutPermissions(appConfigurationCaptor, "org1"); + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertSuccessfulAppConfig(checkResult); + assertThat(checkResult.installations()) + .extracting(InstallationStatus::organization, InstallationStatus::autoProvisioning) + .containsExactly(tuple("org1", ConfigStatus.failed(MISSING_MEMBERS_PERMISSION))); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + @Test + public void checkConfig_whenInstallationSuspended_shouldReturnFailedInstallationAutoProvisioningCheck() { + mockGithubConfiguration(); + ArgumentCaptor appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockSuspendedOrganizations("org1"); + ConfigCheckResult checkResult = configValidator.checkConfig(); + + assertSuccessfulAppConfig(checkResult); + assertThat(checkResult.installations()) + .extracting(InstallationStatus::organization, InstallationStatus::autoProvisioning) + .containsExactly(tuple("org1", ConfigStatus.failed(SUSPENDED_INSTALLATION))); + verify(githubClient).getWhitelistedGithubAppInstallations(appConfigurationCaptor.capture()); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + } + + @Test + public void checkConfig_whenAllPermissionsAreCorrect_shouldReturnSuccessfulCheck() { + mockGithubConfiguration(); + ArgumentCaptor appConfigurationCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + mockGithubAppWithValidConfig(appConfigurationCaptor); + + mockOrganizations(appConfigurationCaptor, "org1", "org2"); + ConfigCheckResult checkResult = configValidator.checkConfig(); + assertSuccessfulAppConfig(checkResult); + + assertThat(checkResult.installations()) + .extracting(InstallationStatus::organization, InstallationStatus::autoProvisioning) + .containsExactlyInAnyOrder( + tuple("org1", SUCCESS_CHECK), + tuple("org2", ConfigStatus.failed(MISSING_MEMBERS_PERMISSION))); + verifyAppConfiguration(appConfigurationCaptor.getValue()); + + } + + private void mockGithubConfiguration() { + when(gitHubSettings.appId()).thenReturn(APP_ID); + when(gitHubSettings.privateKey()).thenReturn(PRIVATE_KEY); + when(gitHubSettings.apiURLOrDefault()).thenReturn(URL); + } + + private void verifyAppConfiguration(GithubAppConfiguration appConfiguration) { + assertThat(appConfiguration.getId()).isEqualTo(parseLong(APP_ID)); + assertThat(appConfiguration.getPrivateKey()).isEqualTo(PRIVATE_KEY); + assertThat(appConfiguration.getApiEndpoint()).isEqualTo(URL); + } + + private GsonApp mockGithubApp(ArgumentCaptor appConfigurationCaptor) { + GsonApp githubApp = mock(GsonApp.class); + when(githubClient.getApp(appConfigurationCaptor.capture())).thenReturn(githubApp); + return githubApp; + } + + private GsonApp mockGithubAppWithValidConfig(ArgumentCaptor appConfigurationCaptor) { + GsonApp githubApp = mock(GsonApp.class); + when(githubClient.getApp(appConfigurationCaptor.capture())).thenReturn(githubApp); + when(githubApp.getPermissions()).thenReturn(new Permissions(null, "read", "read")); + + return githubApp; + } + + private static void assertSuccessfulAppConfig(ConfigCheckResult checkResult) { + assertThat(checkResult.application().jit()).isEqualTo(ConfigStatus.SUCCESS); + assertThat(checkResult.application().autoProvisioning()).isEqualTo(ConfigStatus.SUCCESS); + } + + private void mockOrganizationsWithoutPermissions(ArgumentCaptor appConfigurationCaptor, String... organizations) { + + List installations = Arrays.stream(organizations).map(GithubProvisioningConfigValidatorTest::mockInstallation).toList(); + when(githubClient.getWhitelistedGithubAppInstallations(appConfigurationCaptor.capture())).thenReturn(installations); + + } + + private void mockSuspendedOrganizations(String orgName) { + GithubAppInstallation installation = new GithubAppInstallation(null, orgName, null, true); + when(githubClient.getWhitelistedGithubAppInstallations(any())).thenReturn(List.of(installation)); + } + + private static GithubAppInstallation mockInstallation(String org) { + GithubAppInstallation installation = mock(GithubAppInstallation.class); + when(installation.organizationName()).thenReturn(org); + when(installation.permissions()).thenReturn(mock(Permissions.class)); + return installation; + } + + private static GithubAppInstallation mockInstallationWithMembersPermission(String org) { + GithubAppInstallation installation = mockInstallation(org); + when(installation.permissions()).thenReturn(new Permissions(null, "read", "read")); + return installation; + } + + private void mockOrganizations(ArgumentCaptor appConfigurationCaptor, String orgWithMembersPermission, String orgWithoutMembersPermission) { + List installations = List.of( + mockInstallationWithMembersPermission(orgWithMembersPermission), + mockInstallation(orgWithoutMembersPermission)); + when(githubClient.getWhitelistedGithubAppInstallations(appConfigurationCaptor.capture())).thenReturn(installations); + } + +} diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/config/CheckActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/config/CheckActionIT.java new file mode 100644 index 00000000000..e4e8963b9c8 --- /dev/null +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/config/CheckActionIT.java @@ -0,0 +1,107 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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.almintegration.ws.github.config; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.apache.commons.io.IOUtils; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.github.config.ConfigCheckResult; +import org.sonar.alm.client.github.config.ConfigCheckResult.ApplicationStatus; +import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator; +import org.sonar.api.server.ws.WebService; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.TestResponse; +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.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.alm.client.github.config.ConfigCheckResult.ConfigStatus; +import static org.sonar.alm.client.github.config.ConfigCheckResult.InstallationStatus; +import static org.sonar.test.JsonAssert.assertJson; + +public class CheckActionIT { + + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + + private GithubProvisioningConfigValidator configValidator = mock(GithubProvisioningConfigValidator.class); + + private final WsActionTester ws = new WsActionTester(new CheckAction(userSession, configValidator)); + + @Test + public void test_definition() { + WebService.Action def = ws.getDef(); + assertThat(def.key()).isEqualTo("check"); + assertThat(def.since()).isEqualTo("10.1"); + assertThat(def.isInternal()).isTrue(); + assertThat(def.isPost()).isTrue(); + assertThat(def.params()).isEmpty(); + assertThat(def.responseExample()).isNotNull(); + assertThat(def.responseExample()).isEqualTo(getClass().getResource("check-example.json")); + } + + @Test + public void check_whenUserIsAdmin_shouldReturnCheckResult() throws IOException { + userSession.logIn().setSystemAdministrator(); + ConfigCheckResult result = new ConfigCheckResult( + new ApplicationStatus( + ConfigStatus.SUCCESS, + ConfigStatus.failed("App validation failed")), + List.of( + new InstallationStatus( + "org1", + ConfigStatus.SUCCESS, + ConfigStatus.SUCCESS + ), + new InstallationStatus( + "org2", + ConfigStatus.SUCCESS, + ConfigStatus.failed("Organization validation failed.") + ) + )); + + when(configValidator.checkConfig()).thenReturn(result); + TestResponse response = ws.newRequest().execute(); + + assertThat(response.getStatus()).isEqualTo(200); + assertJson(response.getInput()).isSimilarTo(readResponse("check-example.json")); + } + + @Test + public void check_whenNotAnAdmin_shouldThrow() { + userSession.logIn("not-an-admin"); + + TestRequest testRequest = ws.newRequest(); + assertThatThrownBy(testRequest::execute) + .hasMessage("Insufficient privileges") + .isInstanceOf(ForbiddenException.class); + } + + private String readResponse(String file) throws IOException { + return IOUtils.toString(getClass().getResource(file), StandardCharsets.UTF_8); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java index 9bf9b138c31..73bec42c67e 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java @@ -32,6 +32,7 @@ import org.sonar.server.almintegration.ws.github.GetGithubClientIdAction; import org.sonar.server.almintegration.ws.github.ImportGithubProjectAction; import org.sonar.server.almintegration.ws.github.ListGithubOrganizationsAction; import org.sonar.server.almintegration.ws.github.ListGithubRepositoriesAction; +import org.sonar.server.almintegration.ws.github.config.CheckAction; import org.sonar.server.almintegration.ws.gitlab.ImportGitLabProjectAction; import org.sonar.server.almintegration.ws.gitlab.SearchGitlabReposAction; @@ -40,6 +41,7 @@ public class AlmIntegrationsWSModule extends Module { protected void configureModule() { add( CheckPatAction.class, + CheckAction.class, SetPatAction.class, ImportBitbucketServerProjectAction.class, ImportBitbucketCloudRepoAction.class, diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningAction.java new file mode 100644 index 00000000000..19e893bfc02 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningAction.java @@ -0,0 +1,25 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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.almintegration.ws.github; + +import org.sonar.server.ws.WsAction; + +public interface GithubProvisioningAction extends WsAction { +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWs.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWs.java new file mode 100644 index 00000000000..9993d9b0999 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWs.java @@ -0,0 +1,46 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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.almintegration.ws.github; + +import java.util.Set; +import org.sonar.api.server.ServerSide; +import org.sonar.api.server.ws.WebService; + +@ServerSide +public class GithubProvisioningWs implements WebService { + + public static final String API_ENDPOINT = "api/github_provisioning"; + + private final Set actions; + + public GithubProvisioningWs(Set actions) { + this.actions = actions; + } + + @Override + public void define(Context context) { + NewController controller = context.createController(API_ENDPOINT) + .setDescription("Manage GitHub provisioning.") + .setSince("10.1"); + + actions.forEach(action -> action.define(controller)); + controller.done(); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/config/CheckAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/config/CheckAction.java new file mode 100644 index 00000000000..5cc7f13d2ab --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/config/CheckAction.java @@ -0,0 +1,69 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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.almintegration.ws.github.config; + +import com.google.gson.GsonBuilder; +import java.nio.charset.StandardCharsets; +import javax.servlet.http.HttpServletResponse; +import org.sonar.alm.client.github.config.ConfigCheckResult; +import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator; +import org.sonar.api.server.ServerSide; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.server.almintegration.ws.github.GithubProvisioningAction; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.MediaTypes; + +@ServerSide +public class CheckAction implements GithubProvisioningAction { + + private final UserSession userSession; + private final GithubProvisioningConfigValidator githubProvisioningConfigValidator; + + public CheckAction(UserSession userSession, GithubProvisioningConfigValidator githubProvisioningConfigValidator) { + this.userSession = userSession; + this.githubProvisioningConfigValidator = githubProvisioningConfigValidator; + } + + @Override + public void define(WebService.NewController controller) { + controller + .createAction("check") + .setPost(true) + .setDescription("Validate Github provisioning configuration.") + .setHandler(this) + .setInternal(true) + .setResponseExample(getClass().getResource("check-example.json")) + .setSince("10.1"); + } + + @Override + public void handle(Request request, Response response) throws Exception { + userSession.checkIsSystemAdministrator(); + + ConfigCheckResult result = githubProvisioningConfigValidator.checkConfig(); + + response.stream().setStatus(HttpServletResponse.SC_OK); + response.stream().setMediaType(MediaTypes.JSON); + response.stream().output().write(new GsonBuilder().create().toJson(result).getBytes(StandardCharsets.UTF_8)); + response.stream().output().flush(); + } +} diff --git a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/github/config/check-example.json b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/github/config/check-example.json new file mode 100644 index 00000000000..eb77c4e4372 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/github/config/check-example.json @@ -0,0 +1,32 @@ +{ + "application": { + "jit": { + "status": "SUCCESS" + }, + "autoProvisioning": { + "status": "FAILED", + "errorMessage": "App validation failed" + } + }, + "installations": [ + { + "organization": "org1", + "jit": { + "status": "SUCCESS" + }, + "autoProvisioning": { + "status": "SUCCESS" + } + }, + { + "organization": "org2", + "jit": { + "status": "SUCCESS" + }, + "autoProvisioning": { + "status": "FAILED", + "errorMessage": "Organization validation failed." + } + } + ] +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWsTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWsTest.java new file mode 100644 index 00000000000..f6dce7a122c --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GithubProvisioningWsTest.java @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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.almintegration.ws.github; + +import java.util.Set; +import org.junit.Test; +import org.sonar.api.server.ws.WebService; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.only; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class GithubProvisioningWsTest { + @Test + public void define_createsOneController() { + WebService.Context context = mock(WebService.Context.class); + GithubProvisioningAction action = mock(GithubProvisioningAction.class); + WebService.NewController controller = mock(WebService.NewController.class); + GithubProvisioningWs scimWs = new GithubProvisioningWs(Set.of(action)); + + when(context.createController("api/github_provisioning")).thenReturn(controller); + when(controller.setDescription(any())).thenReturn(controller); + when(controller.setSince(any())).thenReturn(controller); + + scimWs.define(context); + + verify(context, only()).createController("api/github_provisioning"); + verify(action, only()).define(any()); + } +} 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 d89f0e2280f..3ffdc677d6b 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 @@ -30,6 +30,7 @@ import org.sonar.alm.client.bitbucketserver.BitbucketServerSettingsValidator; import org.sonar.alm.client.github.GithubApplicationClientImpl; import org.sonar.alm.client.github.GithubApplicationHttpClientImpl; import org.sonar.alm.client.github.GithubGlobalSettingsValidator; +import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator; import org.sonar.alm.client.github.security.GithubAppSecurityImpl; import org.sonar.alm.client.gitlab.GitlabGlobalSettingsValidator; import org.sonar.alm.client.gitlab.GitlabHttpClient; @@ -59,6 +60,7 @@ import org.sonar.server.almintegration.ws.AlmIntegrationsWSModule; import org.sonar.server.almintegration.ws.CredentialsEncoderHelper; import org.sonar.server.almintegration.ws.ImportHelper; import org.sonar.server.almintegration.ws.ProjectKeyGenerator; +import org.sonar.server.almintegration.ws.github.GithubProvisioningWs; import org.sonar.server.almsettings.MultipleAlmFeature; import org.sonar.server.almsettings.ws.AlmSettingsWsModule; import org.sonar.server.authentication.AuthenticationModule; @@ -543,6 +545,8 @@ public class PlatformLevel4 extends PlatformLevel { GithubAppSecurityImpl.class, GithubApplicationClientImpl.class, GithubApplicationHttpClientImpl.class, + GithubProvisioningConfigValidator.class, + GithubProvisioningWs.class, BitbucketCloudRestClientConfiguration.class, BitbucketServerRestClient.class, GitlabHttpClient.class, -- 2.39.5